diff --git a/config/vufind/RecordTabs.ini b/config/vufind/RecordTabs.ini
index 778f986418bac43f89950c880413c93c0d79be46..95f776008847f2ed3328599b715158d629391c2d 100644
--- a/config/vufind/RecordTabs.ini
+++ b/config/vufind/RecordTabs.ini
@@ -62,6 +62,7 @@ tabs[HierarchyTree] = HierarchyTree
 ;tabs[CollectionList] = CollectionList
 ;tabs[CollectionHierarchyTree] = CollectionHierarchyTree
 tabs[Map] = Map
+tabs[Versions] = Versions
 tabs[Similar] = SimilarItemsCarousel
 tabs[Details] = StaffViewArray
 defaultTab = null
@@ -83,6 +84,7 @@ tabs[HierarchyTree] = HierarchyTree
 ;tabs[Search2CollectionList] = Search2CollectionList
 ;tabs[CollectionHierarchyTree] = CollectionHierarchyTree
 tabs[Map] = Map
+tabs[Versions] = Versions
 tabs[Similar] = SimilarItemsCarousel
 tabs[Details] = StaffViewArray
 defaultTab = null
@@ -102,6 +104,7 @@ tabs[Excerpt] = Excerpt
 tabs[Preview] = preview
 tabs[HierarchyTree] = HierarchyTree
 tabs[Map] = Map
+tabs[Versions] = Versions
 tabs[Similar] = SimilarItemsCarousel
 tabs[Details] = StaffViewMARC
 defaultTab = null
@@ -116,6 +119,7 @@ tabs[Excerpt] = Excerpt
 tabs[Preview] = preview
 tabs[HierarchyTree] = HierarchyTree
 tabs[Map] = Map
+tabs[Versions] = Versions
 tabs[Similar] = SimilarItemsCarousel
 tabs[Details] = StaffViewOverdrive
 defaultTab = null
@@ -144,3 +148,7 @@ defaultTab = null
 ; by a tab. Settings are named by the tab name (X above) and may be repeated if
 ; multiple scripts are required.
 [TabScripts]
+Versions[] = openurl.js
+Versions[] = combined-search.js
+Versions[] = check_item_statuses.js
+Versions[] = record_versions.js
diff --git a/config/vufind/Search2.ini b/config/vufind/Search2.ini
index 44879ebded9e0fb5d7c5fc33e8966533c36fc1d6..5d18744c7a48ef3b3f9a8fe71fcdf65ca947ce2f 100644
--- a/config/vufind/Search2.ini
+++ b/config/vufind/Search2.ini
@@ -63,6 +63,7 @@ retain_filters_by_default = true
 ;default_filters[] = "institution:MyInstitution"
 ;default_filters[] = "(format:Book AND institution:MyInstitution)"
 ;default_record_fields = "*,score"
+display_versions = true
 
 [Cache]
 type = File
@@ -103,6 +104,7 @@ title = sort_title
 
 [DefaultSortingByType]
 CallNumber = callnumber-sort
+WorkKeys = year
 
 [SideRecommendations]
 ;Subject[]          = SideFacets
@@ -113,6 +115,7 @@ Author[]            = AuthorFacets
 Author[]            = SpellingSuggestions
 ;Author[]           = WorldCatIdentities
 CallNumber[]        = "TopFacets:ResultsTop"
+WorkKeys[]          = false
 
 [NoResultsRecommendations]
 CallNumber[] = SwitchQuery::wildcard:truncatechar
diff --git a/config/vufind/searches.ini b/config/vufind/searches.ini
index 65d8446857f98d3484b28f64f7401eba155fe7a6..4ee8c8899bd0debd617fd39a822f930031584dd9 100644
--- a/config/vufind/searches.ini
+++ b/config/vufind/searches.ini
@@ -109,6 +109,9 @@ retain_filters_by_default = true
 ; line below.
 ;default_record_fields = "*,score"
 
+; Whether the "versions" (FRBR) link and record tab are enabled. Default is true.
+;display_versions = true
+
 [Cache]
 ; This controls whether the parsed searchspecs.yaml file will be stored to
 ; improve search performance; legal options are APC (use APC cache), File (store
@@ -186,6 +189,7 @@ title = sort_title
 ; using the default_sort setting in the [General] section above.
 [DefaultSortingByType]
 CallNumber = callnumber-sort
+WorkKeys = year
 
 ; Each search type defined in searchspecs.yaml can have one or more "recommendations
 ; modules" associated with it in the following sections.  These plug-ins will cause
@@ -440,11 +444,14 @@ Author[]            = AuthorFacets
 Author[]            = SpellingSuggestions
 ;Author[]           = WorldCatIdentities
 CallNumber[]        = "TopFacets:ResultsTop"    ; disable spelling in this context
+; Do not show top recommendations for the Versions (WorkKeys) search by default
+WorkKeys[]          = false
 
 [NoResultsRecommendations]
 CallNumber[] = SwitchQuery::wildcard:truncatechar
 CallNumber[] = RemoveFilters
 ;CallNumber[] = AlphaBrowseLink:lcc
+WorkKeys[]   = false
 
 ; These settings control the top and side recommendations within the special Author
 ; module (the page accessed by clicking on an author's name within the search
diff --git a/config/vufind/searchspecs.yaml b/config/vufind/searchspecs.yaml
index a3fd4a6ead7779825888e36e10f64412a7f0799d..249ba430d391ec5ad685c28088f42a96af2f02f0 100644
--- a/config/vufind/searchspecs.yaml
+++ b/config/vufind/searchspecs.yaml
@@ -431,3 +431,9 @@ oclc_num:
   QueryFields:
     oclc_num:
       - [oclc_num, ~]
+
+WorkKeys:
+  QueryFields:
+    work_keys_str_mv:
+      - [and, 100]
+      - [or, ~]
diff --git a/import-marc.bat b/import-marc.bat
index dca28dbb708a9f28c4d6054f6a37df5e363b514e..fb2278bfc27f602294070d0feac9c406897d7d26 100644
--- a/import-marc.bat
+++ b/import-marc.bat
@@ -119,7 +119,7 @@ for %%a in (%VUFIND_HOME%\import\solrmarc_core_*.jar) do set JAR_FILE=%%a
 rem #####################################################
 rem # Execute Importer
 rem #####################################################
-set RUN_CMD=%JAVA% %INDEX_OPTIONS% -Duser.timezone=UTC -Dlog4j.configuration="file:///%LOG4J_CONFIG%" %EXTRA_SOLRMARC_SETTINGS% -jar %JAR_FILE% %PROPERTIES_FILE% -solrj %VUFIND_HOME%\solr\vendor\dist\solrj-lib %1
+set RUN_CMD=%JAVA% %INDEX_OPTIONS% -Duser.timezone=UTC -Dlog4j.configuration="file:///%LOG4J_CONFIG%" %EXTRA_SOLRMARC_SETTINGS% -jar %JAR_FILE% %PROPERTIES_FILE% -solrj %VUFIND_HOME%\solr\vendor\dist\solrj-lib -lib_local %VUFIND_HOME%\import\lib_local;%VUFIND_HOME%\solr\vendor\contrib\analysis-extras\lib %1
 echo Now Importing %1 ...
 echo %RUN_CMD%
 %RUN_CMD%
diff --git a/import-marc.sh b/import-marc.sh
index 309cc11501e7a5cf98206ab2fba0a819291a3b41..8c3cd3aa66b0a6aa4e4eab3009622786e0e7fd78 100755
--- a/import-marc.sh
+++ b/import-marc.sh
@@ -140,7 +140,7 @@ MARC_FILE=`basename $1`
 # Execute Importer
 #####################################################
 
-RUN_CMD="$JAVA $INDEX_OPTIONS -Duser.timezone=UTC -Dlog4j.configuration=file://$LOG4J_CONFIG $EXTRA_SOLRMARC_SETTINGS -jar $JAR_FILE $PROPERTIES_FILE -solrj $VUFIND_HOME/solr/vendor/dist/solrj-lib $MARC_PATH/$MARC_FILE"
+RUN_CMD="$JAVA $INDEX_OPTIONS -Duser.timezone=UTC -Dlog4j.configuration=file://$LOG4J_CONFIG $EXTRA_SOLRMARC_SETTINGS -jar $JAR_FILE $PROPERTIES_FILE -solrj $VUFIND_HOME/solr/vendor/dist/solrj-lib -lib_local "$VUFIND_HOME/import/lib_local\;$VUFIND_HOME/solr/vendor/contrib/analysis-extras/lib" $MARC_PATH/$MARC_FILE"
 echo "Now Importing $1 ..."
 # solrmarc writes log messages to stderr, write RUN_CMD to the same place
 echo "`date '+%h %d, %H:%M:%S'` $RUN_CMD" >&2
diff --git a/import/archivesspace.properties b/import/archivesspace.properties
index 108e6f6b6fbc90b1861a7dd4db735b54b8151f18..119a7dfc5949820c45dccb380347fa7c6373eaec 100644
--- a/import/archivesspace.properties
+++ b/import/archivesspace.properties
@@ -11,6 +11,7 @@ xslt = archivesspace.xsl
 ; name; if you do not include a namespace, the class will automatically be assumed
 ; to live in the \VuFind\XSLT\Import namespace.
 custom_class[] = VuFind
+custom_class[] = VuFindWorkKeys
 ; OPTIONAL: If true, all custom_class settings above will be passed to the XSLT with
 ; their namespaces stripped off; for example, \VuFind\XSLT\Import\VuFind would be
 ; treated as \VuFind in XSLT files.  This allows more compact syntax within XSLT
@@ -28,3 +29,13 @@ collection = "Archives"
 ; specify a more narrow prefix here if you wish to filter to a particular subset
 ; of URLs indexed into VuFind.
 ;urlPrefix = "http://hdl.handle.net"
+
+; These settings will influence work key generation for identifying record versions.
+; You can define regular expressions to either specifically include or specifically
+; exclude particular characters, and/or you can use Transliterator rules when
+; generating keys to identify works. See
+; https://unicode-org.github.io/icu/userguide/transforms/general/#icu-transliterators
+; for more information on the transliteration rules.
+workKey_include_regEx = ""
+workKey_exclude_regEx = ""
+workKey_transliterator_rules = ":: NFD; :: lower; :: Latin; :: [^[:letter:] [:number:]] Remove; :: NFKC;"
diff --git a/import/index_java/src/org/vufind/index/FieldSpecTools.java b/import/index_java/src/org/vufind/index/FieldSpecTools.java
index 821073d657f3553ea77ac937bf39c42b21abda0d..3a572410a4fb5dabf2673707f5e7f904d21a0981 100644
--- a/import/index_java/src/org/vufind/index/FieldSpecTools.java
+++ b/import/index_java/src/org/vufind/index/FieldSpecTools.java
@@ -20,9 +20,14 @@ package org.vufind.index;
 
 import org.marc4j.marc.Record;
 import org.marc4j.marc.DataField;
+import org.marc4j.marc.Subfield;
 
+import org.solrmarc.index.extractor.formatter.FieldFormatter;
+import org.solrmarc.index.extractor.formatter.FieldFormatterBase;
+import org.solrmarc.index.extractor.formatter.FieldFormatter.eCleanVal;
 import org.solrmarc.index.SolrIndexer;
 
+import java.lang.StringBuilder;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
@@ -71,16 +76,32 @@ public class FieldSpecTools
      * @return Set
      */
     public static final Set<String> getFieldsByTagList(final Record record, final String tagList)
+    {
+        return getFieldsByTagList(record, tagList, false);
+    }
+
+    /**
+     * Get field data specified by a SolrMarc tag list
+     *
+     * @param record          Record
+     * @param tagList         The field specification
+     * @param removeNonFiling Whether to remove non-filing characters
+     *
+     * @return Set
+     */
+    public static final Set<String> getFieldsByTagList(final Record record, final String tagList, Boolean removeNonFiling)
     {
         Set<String> result = new LinkedHashSet<String>();
         final HashMap<String, Set<String>> parsedTagList = getParsedTagList(tagList);
         final List fields = SolrIndexer.instance().getFieldSetMatchingTagList(record, tagList);
+        final FieldFormatter formatter = removeNonFiling
+            ? new FieldFormatterBase(false).addCleanVal(eCleanVal.STRIP_INDICATOR) : null;
         if (fields != null) {
             Iterator fieldsIter = fields.iterator();
             while (fieldsIter.hasNext()) {
                 DataField field = (DataField) fieldsIter.next();
                 for (String subfields : parsedTagList.get(field.getTag())) {
-                    String current = SolrIndexer.instance().getDataFromVariableField(field, "["+subfields+"]", " ", false);
+                    String current = getFieldData(field, subfields, formatter);
                     if (null != current) {
                         result.add(current);
                     }
@@ -89,4 +110,35 @@ public class FieldSpecTools
         }
         return result;
     }
+
+    /**
+     * Get subfields from a data field
+     *
+     * @param dataFiel     Data field
+     * @param subfieldCode Subfield codes to get
+     * @param formatter    Formatter to use (or null)
+     *
+     * @return Set
+     */
+    protected static final String getFieldData(DataField dataField, String subfieldCodes, FieldFormatter formatter)
+    {
+        StringBuilder result = new StringBuilder(64);
+        final List<Subfield> subfields = dataField.getSubfields();
+        for (Subfield subfield : subfields) {
+            final char subfieldCode = subfield.getCode();
+            if (subfieldCodes.indexOf(subfieldCode) != -1) {
+                if (result.length() > 0) {
+                    result.append(' ');
+                }
+                final String subfieldData = subfield.getData().trim();
+                if (null != formatter) {
+                    result.append(formatter.cleanData(dataField, 'a' == subfieldCode, subfieldData));
+                } else {
+                    result.append(subfieldData);
+                }
+            }
+        }
+
+        return result.length() > 0 ? result.toString() : null;
+    }
 }
diff --git a/import/index_java/src/org/vufind/index/WorkKeys.java b/import/index_java/src/org/vufind/index/WorkKeys.java
new file mode 100644
index 0000000000000000000000000000000000000000..4725fb363d495a6eb09d7e152d1829dff7e5d7f1
--- /dev/null
+++ b/import/index_java/src/org/vufind/index/WorkKeys.java
@@ -0,0 +1,142 @@
+package org.vufind.index;
+
+/**
+ * Indexing routines for creating work keys for FRBR functionality.
+ *
+ * Copyright (C) The National Library of Finland 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ */
+
+import org.marc4j.marc.Record;
+
+import org.vufind.index.FieldSpecTools;
+
+import com.ibm.icu.text.Transliterator;
+
+import java.text.Normalizer;
+import java.text.Normalizer.Form;
+import java.util.Hashtable;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Indexing routines for creating work keys for FRBR functionality.
+ */
+public class WorkKeys
+{
+    /**
+     * Cache for Transliterator instances to avoid having to recreate them
+     */
+    Hashtable<String, Transliterator> transliterators = new Hashtable<String, Transliterator>();
+
+    /**
+     * Get all work identification keys for the record.
+     *
+     * @param  record               MARC record
+     * @param  uniformTitleTagList  The field specification for uniform titles
+     * @param  titleTagList         The field specification for titles
+     * @param  titleTagListNF       The field specification for titles with
+     * non-filing characters removed
+     * @param  authorTagList        The field specification for authors
+     * @param  includeRegEx         Regular expression defining characters to keep
+     * @param  excludeRegEx         Regular expression defining characters to remove
+     * @param  transliterationRules ICU transliteration rules to be applied before
+     * the include and exclude regex's. See
+     * https://unicode-org.github.io/icu/userguide/transforms/general/#icu-transliterators
+     * for more information.
+     * @return set of keys
+     */
+    public Set<String> getWorkKeys(final Record record, final String uniformTitleTagList,
+        final String titleTagList, final String titleTagListNF, final String authorTagList,
+        final String includeRegEx, final String excludeRegEx, final String transliterationRules
+
+    ) {
+        Set<String> workKeys = new LinkedHashSet<String>();
+
+        if (!transliterationRules.isEmpty()) {
+            if (!this.transliterators.containsKey(transliterationRules)) {
+                this.transliterators.put(
+                    transliterationRules,
+                    Transliterator.createFromRules("workkeys", transliterationRules, Transliterator.FORWARD)
+                );
+            }
+        }
+        final Transliterator transliterator = transliterationRules.isEmpty()
+            ? null : this.transliterators.get(transliterationRules);
+
+        // Uniform title
+        final Set<String> uniformTitles = FieldSpecTools.getFieldsByTagList(record, uniformTitleTagList);
+        for (String uniformTitle : uniformTitles) {
+            final String normalizedUniformTitle
+                = normalizeWorkKey(uniformTitle, includeRegEx, excludeRegEx, transliterator);
+            if (!normalizedUniformTitle.isEmpty()) {
+                workKeys.add("UT ".concat(normalizedUniformTitle));
+            }
+        }
+
+        // Title + Author
+        Set<String> titles = FieldSpecTools.getFieldsByTagList(record, titleTagList);
+        titles.addAll(FieldSpecTools.getFieldsByTagList(record, titleTagListNF, true));
+        final Set<String> authors = FieldSpecTools.getFieldsByTagList(record, authorTagList);
+
+        if (!authors.isEmpty()) {
+            for (String title : titles) {
+                final String normalizedTitle
+                    = normalizeWorkKey(title, includeRegEx, excludeRegEx, transliterator);
+                if (!normalizedTitle.isEmpty()) {
+                    for (String author : authors) {
+                        final String normalizedAuthor
+                            = normalizeWorkKey(author, includeRegEx, excludeRegEx, transliterator);
+                        if (!normalizedAuthor.isEmpty()) {
+                            workKeys.add("AT ".concat(normalizedAuthor).concat(" ").concat(normalizedTitle));
+                        }
+                    }
+                }
+            }
+        }
+
+        return workKeys;
+    }
+
+    /**
+     * Create a key string
+     *
+     * @param  s              String to normalize
+     * @param  includeRegEx   Regular expression defining characters to keep
+     * @param  excludeRegEx   Regular expression defining characters to remove
+     * @param  transliterator Optional ICU transliterator to use
+     */
+    protected String normalizeWorkKey(final String s, final String includeRegEx, final String excludeRegEx,
+        final Transliterator transliterator
+    ) {
+        String normalized = transliterator != null ? transliterator.transliterate(s)
+            : Normalizer.normalize(s, Normalizer.Form.NFKC);
+        if (!includeRegEx.isBlank()) {
+            StringBuilder result = new StringBuilder();
+            Matcher m = Pattern.compile(includeRegEx).matcher(normalized);
+            while (m.find()) {
+                result.append(m.group());
+            }
+            normalized = result.toString();
+        }
+        if (!excludeRegEx.isBlank()) {
+            normalized = normalized.replaceAll(excludeRegEx, "");
+        }
+        int length = normalized.length();
+        return normalized.toLowerCase().substring(0, length > 255 ? 255 : length);
+    }
+}
diff --git a/import/xsl/archivesspace.xsl b/import/xsl/archivesspace.xsl
index 61a700856979152d77524c9896bce9ab3857dd8f..e260200ac7faabdb705267a39dd6676aae78e27e 100644
--- a/import/xsl/archivesspace.xsl
+++ b/import/xsl/archivesspace.xsl
@@ -9,6 +9,9 @@
     <xsl:param name="institution">My University</xsl:param>
     <xsl:param name="collection">Archives</xsl:param>
     <xsl:param name="urlPrefix">http</xsl:param>
+    <xsl:param name="workKey_include_regEx"></xsl:param>
+    <xsl:param name="workKey_exclude_regEx"></xsl:param>
+    <xsl:param name="workKey_transliterator_rules">:: NFD; :: lower; :: Latin; :: [^[:letter:] [:number:]] Remove; :: NFKC;</xsl:param>
     <xsl:template match="oai_dc:dc">
         <add>
             <doc>
@@ -145,6 +148,13 @@
                         </field>
                     </xsl:if>
                 </xsl:for-each>
+
+                <!-- Work Keys -->
+                <xsl:for-each select="php:function('VuFindWorkKeys::getWorkKeys', '', //dc:title[normalize-space()], php:function('VuFind::stripArticles', string(//dc:title[normalize-space()])), //dc:creator, $workKey_include_regEx, $workKey_exclude_regEx, $workKey_transliterator_rules)/workKey">
+                    <field name="work_keys_str_mv">
+                        <xsl:value-of select="." />
+                    </field>
+                </xsl:for-each>
             </doc>
         </add>
     </xsl:template>
diff --git a/languages/en.ini b/languages/en.ini
index 80d52ad39d064c4a0a44d27fd7bb998998535193..8e17f613fb4944afda41f068205ea2bba92c87c8 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -858,6 +858,9 @@ Other Authors = "Other Authors"
 Other Editions = "Other Editions"
 Other Libraries = "Other Libraries"
 Other Sources = "Other Sources"
+other_versions_link = "Show other versions (%%count%%)"
+other_versions_search_link = "Show all versions (%%count%%)"
+other_versions_title = "Other Versions (%%count%%)"
 Page not found. = "Page not found."
 page_first = "Go to First Page"
 page_last = "Go to Last Page"
@@ -1237,6 +1240,7 @@ verification_email_subject = "VuFind Email Verification"
 verification_email_url_pretext = "You can verify your email address at this URL: %%url%%"
 verification_too_soon = "Your email requires validation. An email was recently sent to your registered email address. If you did not receive it, please wait a few minutes and try again."
 verification_user_not_found = "We could not find your account"
+Versions = "Versions"
 VHS = "VHS"
 Video = "Video"
 Video Clips = "Video Clips"
diff --git a/languages/fi.ini b/languages/fi.ini
index 2e6d17bc3c5247970fa54fe4e471a2d1e9d5247c..5f9dfaf9b6b401380af73ceb0e0598dfdbee3c8e 100644
--- a/languages/fi.ini
+++ b/languages/fi.ini
@@ -862,6 +862,9 @@ Other Authors = "Muut tekijät"
 Other Editions = "Muut painokset"
 Other Libraries = "Muut kirjastot"
 Other Sources = "Muut lähteet"
+other_versions_link = "Näytä muut versiot (%%count%%)"
+other_versions_search_link = "Näytä kaikki versiot (%%count%%)"
+other_versions_title = "Muut versiot (%%count%%)"
 Page not found. = "Sivua ei löytynyt."
 page_first = "Siirry ensimmäiselle sivulle"
 page_last = "Siirry viimeiselle sivulle"
@@ -1241,6 +1244,7 @@ verification_email_subject = "VuFindin sähköpostiosoitteen vahvistaminen"
 verification_email_url_pretext = "Voit vahvistaa sähköpostiosoitteesi seuraavassa osoitteessa: %%url%%"
 verification_too_soon = "Sähköpostiosoitteesi tulee vahvistaa. Sähköpostiosoitteeseesi on lähetetty viesti vahvistamisesta. Jos et saanut viestiä, yritä uudelleen muutaman minuutin kuluttua."
 verification_user_not_found = "Tiliäsi ei löytynyt"
+Versions = "Versiot"
 VHS = "VHS"
 Video = "Video"
 Video Clips = "Videoleikkeet"
diff --git a/languages/sv.ini b/languages/sv.ini
index 1c58cab1c389a26152552c50c2ba524c2915e536..1c52c302fc4c4a9b9fa61f14d2fbed96f4c1dff7 100644
--- a/languages/sv.ini
+++ b/languages/sv.ini
@@ -856,6 +856,9 @@ Other Authors = "Övriga upphovsmän"
 Other Editions = "Andra upplagor"
 Other Libraries = "Andra bibliotek"
 Other Sources = "Andra källor"
+other_versions_link = "Visa andra versioner (%%count%%)"
+other_versions_search_link = "Visa alla versioner (%%count%%)"
+other_versions_title = "Andra versioner (%%count%%)"
 Page not found. = "Sidan hittades inte."
 page_first = "Gå till första sida"
 page_last = "GÃ¥ till sista sida"
@@ -1235,6 +1238,7 @@ verification_email_subject = "Verifiering av e-postadress i VuFind"
 verification_email_url_pretext = "Du kan verifiera din e-postadress med detta länk: %%url%%"
 verification_too_soon = "Din e-postadress kräver verifiering. En meddelande har skickats till din e-postadress. Om du inte får meddelanden, var god försök på nytt efter en stund."
 verification_user_not_found = "Ditt konto hittades inte"
+Versions = "Versioner"
 VHS = "VHS"
 Video = "Video"
 Video Clips = "Videoklipp"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index f93e5666e989c2c568a5600901b3b5bb8bf7cdc4..832d18ee5983afc9688f5412c4ca9c9b3698eba0 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -674,8 +674,9 @@ $staticRoutes = [
     'Search/EditMemory', 'Search/Email',
     'Search/FacetList', 'Search/History', 'Search/Home', 'Search/NewItem',
     'Search/OpenSearch', 'Search/Reserves', 'Search/ReservesFacetList',
-    'Search/Results', 'Search/Suggest',
-    'Search2/Advanced', 'Search2/Home', 'Search2/Results',
+    'Search/Results', 'Search/Suggest', 'Search/Versions',
+    'Search2/Advanced', 'Search2/FacetList', 'Search2/Home', 'Search2/Results',
+    'Search2/Versions',
     'Summon/Advanced', 'Summon/FacetList', 'Summon/Home', 'Summon/Search',
     'Tag/Home',
     'Upgrade/Home', 'Upgrade/FixAnonymousTags', 'Upgrade/FixDuplicateTags',
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordVersions.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordVersions.php
new file mode 100644
index 0000000000000000000000000000000000000000..5221ceacb133756b608f763cff67f0f541bd8900
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordVersions.php
@@ -0,0 +1,108 @@
+<?php
+/**
+ * AJAX handler for fetching versions link
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Laminas\Mvc\Controller\Plugin\Params;
+use VuFind\Record\Loader;
+use VuFind\RecordTab\TabManager;
+use VuFind\Session\Settings as SessionSettings;
+use VuFind\View\Helper\Root\Record;
+
+/**
+ * AJAX handler for fetching versions link
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordVersions extends \VuFind\AjaxHandler\AbstractBase
+{
+    /**
+     * Record loader
+     *
+     * @var Loader
+     */
+    protected $recordLoader;
+
+    /**
+     * Record plugin
+     *
+     * @var Record
+     */
+    protected $recordPlugin;
+
+    /**
+     * Tab manager
+     *
+     * @var TabManager
+     */
+    protected $tabManager;
+
+    /**
+     * Constructor
+     *
+     * @param SessionSettings $ss     Session settings
+     * @param Loader          $loader Record loader
+     * @param Record          $rp     Record plugin
+     * @param TabManager      $tm     Tab manager
+     */
+    public function __construct(SessionSettings $ss, Loader $loader, Record $rp,
+        TabManager $tm
+    ) {
+        $this->sessionSettings = $ss;
+        $this->recordLoader = $loader;
+        $this->recordPlugin = $rp;
+        $this->tabManager = $tm;
+    }
+
+    /**
+     * Handle a request.
+     *
+     * @param Params $params Parameter helper from controller
+     *
+     * @return array [response data, HTTP status code]
+     */
+    public function handleRequest(Params $params)
+    {
+        $this->disableSessionWrites(); // avoid session write timing bug
+
+        $id = $params->fromPost('id') ?: $params->fromQuery('id');
+        $source = $params->fromPost('source') ?: $params->fromQuery('source');
+        $driver = $this->recordLoader->load($id, $source);
+        $tabs = $this->tabManager->getTabsForRecord($driver);
+        $full = true;
+
+        $html = ($this->recordPlugin)($driver)->renderTemplate(
+            'versions-link.phtml', compact('driver', 'tabs', 'full')
+        );
+
+        return $this->formatResponse($html);
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/GetRecordVersionsFactory.php b/module/VuFind/src/VuFind/AjaxHandler/GetRecordVersionsFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c53721ab14685af47b68892e376729f84bcf566
--- /dev/null
+++ b/module/VuFind/src/VuFind/AjaxHandler/GetRecordVersionsFactory.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Factory for GetRecordVersions AJAX handler.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\AjaxHandler;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for GetRecordVersions AJAX handler.
+ *
+ * @category VuFind
+ * @package  AJAX
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class GetRecordVersionsFactory
+    implements \Laminas\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        $result = new $requestedName(
+            $container->get(\VuFind\Session\Settings::class),
+            $container->get(\VuFind\Record\Loader::class),
+            $container->get('ViewRenderer')->plugin('record'),
+            $container->get(\VuFind\RecordTab\TabManager::class)
+        );
+        return $result;
+    }
+}
diff --git a/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php b/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
index d8b6a958df96825598a4e14f10637a75e5c0444b..2a2546079bb4f709ec428e79a90afa0c998da882 100644
--- a/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
+++ b/module/VuFind/src/VuFind/AjaxHandler/PluginManager.php
@@ -57,6 +57,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         'getRecordCover' => GetRecordCover::class,
         'getRecordDetails' => GetRecordDetails::class,
         'getRecordTags' => GetRecordTags::class,
+        'getRecordVersions' => GetRecordVersions::class,
         'getRequestGroupPickupLocations' => GetRequestGroupPickupLocations::class,
         'getResolverLinks' => GetResolverLinks::class,
         'getSaveStatuses' => GetSaveStatuses::class,
@@ -95,6 +96,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         GetRecordCover::class => GetRecordCoverFactory::class,
         GetRecordDetails::class => GetRecordDetailsFactory::class,
         GetRecordTags::class => GetRecordTagsFactory::class,
+        GetRecordVersions::class => GetRecordVersionsFactory::class,
         GetRequestGroupPickupLocations::class =>
             AbstractIlsAndUserActionFactory::class,
         GetResolverLinks::class => GetResolverLinksFactory::class,
diff --git a/module/VuFind/src/VuFind/Config/Upgrade.php b/module/VuFind/src/VuFind/Config/Upgrade.php
index 1fe1d0a753af903c34b906f6bda1fc5b3b2bfa33..1c047a69a4477115a3dd42a5e12a4747e688f974 100644
--- a/module/VuFind/src/VuFind/Config/Upgrade.php
+++ b/module/VuFind/src/VuFind/Config/Upgrade.php
@@ -876,7 +876,7 @@ class Upgrade
                 }
             }
         }
-        $this->upgradeSpellingSettings('searches.ini', ['CallNumber']);
+        $this->upgradeSpellingSettings('searches.ini', ['CallNumber', 'WorkKeys']);
 
         // save the file
         $this->saveModifiedConfig('searches.ini');
diff --git a/module/VuFind/src/VuFind/Controller/AbstractSolrSearch.php b/module/VuFind/src/VuFind/Controller/AbstractSolrSearch.php
index 179cfa2acae20b207ec15d6e0e2bb0671c3253fb..9fd2f3e6ef84ce988d0bcdc99e37944f1b39dc02 100644
--- a/module/VuFind/src/VuFind/Controller/AbstractSolrSearch.php
+++ b/module/VuFind/src/VuFind/Controller/AbstractSolrSearch.php
@@ -38,6 +38,8 @@ namespace VuFind\Controller;
  */
 class AbstractSolrSearch extends AbstractSearch
 {
+    use Feature\RecordVersionsSearchTrait;
+
     /**
      * Handle an advanced search
      *
diff --git a/module/VuFind/src/VuFind/Controller/Feature/RecordVersionsSearchTrait.php b/module/VuFind/src/VuFind/Controller/Feature/RecordVersionsSearchTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..143c970f02ac8a488b4bb7598eab745aaeca2f96
--- /dev/null
+++ b/module/VuFind/src/VuFind/Controller/Feature/RecordVersionsSearchTrait.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * VuFind Action Feature Trait - Record Versions Search
+ * Depends on method getSearchResultsView and record driver's method getWorkKeys.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Controller_Plugins
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFind\Controller\Feature;
+
+/**
+ * VuFind Action Feature Trait - Record Versions Search
+ *
+ * @category VuFind
+ * @package  Controller_Plugins
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+trait RecordVersionsSearchTrait
+{
+    /**
+     * Show results of versions search.
+     *
+     * @return mixed
+     */
+    public function versionsAction()
+    {
+        $id = $this->params()->fromQuery('id');
+        $keys = $this->params()->fromQuery('keys');
+        $record = null;
+        if ($id) {
+            $loader = $this->serviceLocator->get(\VuFind\Record\Loader::class);
+            $record = $loader->load($id, $this->searchClassId, true);
+            if ($record instanceof \VuFind\RecordDriver\Missing) {
+                $record = null;
+            } else {
+                $keys = $record->tryMethod('getWorkKeys');
+            }
+        }
+
+        if (empty($keys)) {
+            return $this->forwardTo('Search', 'Home');
+        }
+
+        $mapFunc = function ($val) {
+            return '"' . addcslashes($val, '"') . '"';
+        };
+
+        $query = $this->getRequest()->getQuery();
+        $query->lookfor = implode(' OR ', array_map($mapFunc, (array)$keys));
+        $query->type = 'WorkKeys';
+
+        // Don't save to history -- history page doesn't handle correctly:
+        $this->saveToHistory = false;
+
+        $callback = function ($runner, $params, $searchId) {
+            $defaultCallback = is_callable([$this, 'getSearchSetupCallback'])
+                ? $this->getSearchSetupCallback() : null;
+            if (is_callable($defaultCallback)) {
+                $defaultCallback($runner, $params, $searchId);
+            }
+            $options = $params->getOptions();
+            $options->disableHighlighting();
+            $options->spellcheckEnabled(false);
+        };
+
+        $view = $this->getSearchResultsView($callback);
+
+        // Customize the URL helper to make sure it builds proper versions URLs
+        // (but only do this if we have access to a results object, which we
+        // won't in RSS mode):
+        if (isset($view->results)) {
+            $view->results->getUrlQuery()
+                ->setDefaultParameter('id', $id)
+                ->setDefaultParameter('keys', $keys)
+                ->setSuppressQuery(true);
+            $view->driver = $record;
+        }
+
+        return $view;
+    }
+}
diff --git a/module/VuFind/src/VuFind/RecordDriver/Feature/VersionAwareInterface.php b/module/VuFind/src/VuFind/RecordDriver/Feature/VersionAwareInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..a79edfb4337c0082c5565f6900a1ed767627ceac
--- /dev/null
+++ b/module/VuFind/src/VuFind/RecordDriver/Feature/VersionAwareInterface.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Version aware marker interface.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  RecordDrivers
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+namespace VuFind\RecordDriver\Feature;
+
+/**
+ * Version aware marker interface.
+ *
+ * @category VuFind
+ * @package  RecordDrivers
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+interface VersionAwareInterface
+{
+    /**
+     * Get work identification keys
+     *
+     * @return array
+     */
+    public function getWorkKeys();
+}
diff --git a/module/VuFind/src/VuFind/RecordDriver/Feature/VersionAwareTrait.php b/module/VuFind/src/VuFind/RecordDriver/Feature/VersionAwareTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..c62ea4ff4a89266de9f884bbd6d58b134c117486
--- /dev/null
+++ b/module/VuFind/src/VuFind/RecordDriver/Feature/VersionAwareTrait.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Logic for record versions support. Depends on versionAwareInterface.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  RecordDrivers
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+namespace VuFind\RecordDriver\Feature;
+
+/**
+ * Logic for record versions support.
+ *
+ * @category VuFind
+ * @package  RecordDrivers
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+trait VersionAwareTrait
+{
+    /**
+     * Cached result of other versions (work expressions) count
+     *
+     * @var int
+     */
+    protected $otherVersionsCount = null;
+
+    /**
+     * Return count of other versions available
+     *
+     * @return int
+     */
+    public function getOtherVersionCount()
+    {
+        if (null === $this->searchService) {
+            return false;
+        }
+
+        if (!isset($this->otherVersionsCount)) {
+            if (!($workKeys = $this->tryMethod('getWorkKeys'))) {
+                if (!($this instanceof VersionAwareInterface)) {
+                    throw new \Exception(
+                        'VersionAwareTrait requires VersionAwareInterface'
+                    );
+                }
+                return false;
+            }
+
+            $params = new \VuFindSearch\ParamBag();
+            $params->add('rows', 0);
+            $results = $this->searchService->workExpressions(
+                $this->getSourceIdentifier(),
+                $this->getUniqueID(),
+                $workKeys,
+                $params
+            );
+            $this->otherVersionsCount = $results->getTotal();
+        }
+        return $this->otherVersionsCount;
+    }
+
+    /**
+     * Retrieve versions as a search result
+     *
+     * @param bool $includeSelf Whether to include this record
+     * @param int  $count       Maximum number of records to display
+     * @param int  $offset      Start position (0-based)
+     *
+     * @return \VuFindSearch\Response\RecordCollectionInterface
+     */
+    public function getVersions($includeSelf = false, $count = 20, $offset = 0)
+    {
+        if (null === $this->searchService) {
+            return false;
+        }
+
+        if (!($workKeys = $this->getWorkKeys())) {
+            return false;
+        }
+
+        if (!isset($this->otherVersions)) {
+            $params = new \VuFindSearch\ParamBag();
+            $params->add('rows', $count);
+            $params->add('start', $offset);
+            $this->otherVersions = $this->searchService->workExpressions(
+                $this->getSourceIdentifier(),
+                $includeSelf ? '' : $this->getUniqueID(),
+                $workKeys,
+                $params
+            );
+        }
+        return $this->otherVersions;
+    }
+}
diff --git a/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php b/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php
index 375ef1b92bfa18df715e6b7cc6e0bfe565244737..9f4c93ca256c0895ddb28ada2676e96a75ecdadd 100644
--- a/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php
+++ b/module/VuFind/src/VuFind/RecordDriver/SolrDefault.php
@@ -6,6 +6,7 @@
  * PHP version 7
  *
  * Copyright (C) Villanova University 2010.
+ * Copyright (C) The National Library of Finland 2019.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -23,6 +24,7 @@
  * @category VuFind
  * @package  RecordDrivers
  * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development:plugins:record_drivers Wiki
  */
@@ -37,14 +39,16 @@ namespace VuFind\RecordDriver;
  * @category VuFind
  * @package  RecordDrivers
  * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development:plugins:record_drivers Wiki
  *
  * @SuppressWarnings(PHPMD.ExcessivePublicCount)
  */
-class SolrDefault extends DefaultRecord
+class SolrDefault extends DefaultRecord implements Feature\VersionAwareInterface
 {
     use HierarchyAwareTrait;
+    use Feature\VersionAwareTrait;
 
     /**
      * These Solr fields should be used for snippets if available (listed in order
@@ -67,7 +71,8 @@ class SolrDefault extends DefaultRecord
     protected $forbiddenSnippetFields = [
         'author', 'title', 'title_short', 'title_full',
         'title_full_unstemmed', 'title_auth', 'title_sub', 'spelling', 'id',
-        'ctrlnum', 'author_variant', 'author2_variant', 'fullrecord'
+        'ctrlnum', 'author_variant', 'author2_variant', 'fullrecord',
+        'work_keys_str_mv',
     ];
 
     /**
@@ -133,6 +138,7 @@ class SolrDefault extends DefaultRecord
         $this->containerLinking
             = !isset($mainConfig->Hierarchy->simpleContainerLinks)
             ? false : $mainConfig->Hierarchy->simpleContainerLinks;
+
         parent::__construct($mainConfig, $recordConfig, $searchSettings);
     }
 
@@ -298,4 +304,14 @@ class SolrDefault extends DefaultRecord
             && !empty($this->fields['hierarchy_parent_id'])
             ? $this->fields['hierarchy_parent_id'][0] : '';
     }
+
+    /**
+     * Get work identification keys
+     *
+     * @return array
+     */
+    public function getWorkKeys()
+    {
+        return $this->fields['work_keys_str_mv'] ?? [];
+    }
 }
diff --git a/module/VuFind/src/VuFind/RecordTab/PluginManager.php b/module/VuFind/src/VuFind/RecordTab/PluginManager.php
index 23136d73539df3a31030e315fff0d8ca94bcc2b1..ec3cd07191ca367fb9e7d0a85e8f8189f988b6b4 100644
--- a/module/VuFind/src/VuFind/RecordTab/PluginManager.php
+++ b/module/VuFind/src/VuFind/RecordTab/PluginManager.php
@@ -65,6 +65,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         'staffviewoverdrive' => StaffViewOverdrive::class,
         'toc' => TOC::class,
         'usercomments' => UserComments::class,
+        'versions' => Versions::class,
     ];
 
     /**
@@ -92,6 +93,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         StaffViewOverdrive::class => InvokableFactory::class,
         TOC::class => TOCFactory::class,
         UserComments::class => UserCommentsFactory::class,
+        Versions::class => VersionsFactory::class,
     ];
 
     /**
diff --git a/module/VuFind/src/VuFind/RecordTab/Versions.php b/module/VuFind/src/VuFind/RecordTab/Versions.php
new file mode 100644
index 0000000000000000000000000000000000000000..995bde5542c582579ac434a3c561b4268b2f0a7e
--- /dev/null
+++ b/module/VuFind/src/VuFind/RecordTab/Versions.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Versions tab
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2019-2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  RecordTabs
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     http://vufind.org/wiki/vufind2:record_tabs Wiki
+ */
+namespace VuFind\RecordTab;
+
+use VuFind\I18n\Translator\TranslatorAwareInterface;
+use VuFind\I18n\Translator\TranslatorAwareTrait;
+
+/**
+ * Versions tab
+ *
+ * @category VuFind
+ * @package  RecordTabs
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     http://vufind.org/wiki/vufind2:record_tabs Wiki
+ */
+class Versions extends \VuFind\RecordTab\AbstractBase
+    implements TranslatorAwareInterface
+{
+    use TranslatorAwareTrait;
+
+    /**
+     * Main configuration
+     *
+     * @var \Zend\Config\Config
+     */
+    protected $config;
+
+    /**
+     * Search options plugin manager
+     *
+     * @var \VuFind\Search\Options\PluginManager;
+     */
+    protected $searchOptionsManager;
+
+    /**
+     * Constructor
+     *
+     * @param \Zend\Config\Config                  $config Configuration
+     * @param \VuFind\Search\Options\PluginManager $som    Search options plugin
+     * manager
+     */
+    public function __construct(\Laminas\Config\Config $config,
+        \VuFind\Search\Options\PluginManager $som
+    ) {
+        $this->config = $config;
+        $this->searchOptionsManager = $som;
+    }
+
+    /**
+     * Is this tab active?
+     *
+     * @return bool
+     */
+    public function isActive()
+    {
+        $options = $this->searchOptionsManager
+            ->get($this->getRecordDriver()->getSourceIdentifier());
+        return $options->getVersionsAction()
+            && $this->getRecordDriver()->tryMethod('getOtherVersionCount') > 0;
+    }
+
+    /**
+     * Get the on-screen description for this tab.
+     *
+     * @return string
+     */
+    public function getDescription()
+    {
+        $count = $this->getRecordDriver()->tryMethod('getOtherVersionCount');
+        return $this->translate('other_versions_title', ['%%count%%' => $count]);
+    }
+}
diff --git a/module/VuFind/src/VuFind/RecordTab/VersionsFactory.php b/module/VuFind/src/VuFind/RecordTab/VersionsFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..0bef1e4d93286b97e938e9bee5d1c666d1a8e45c
--- /dev/null
+++ b/module/VuFind/src/VuFind/RecordTab/VersionsFactory.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Factory for building the Versions tab.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ * Copyright (C) The National Library of Finland 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  RecordTabs
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\RecordTab;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for building the Versions tab.
+ *
+ * @category VuFind
+ * @package  RecordTabs
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class VersionsFactory implements \Laminas\ServiceManager\Factory\FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+        return new $requestedName(
+            $container->get(\VuFind\Config\PluginManager::class)->get('config'),
+            $container->get(\VuFind\Search\Options\PluginManager::class)
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/Search/Base/Options.php b/module/VuFind/src/VuFind/Search/Base/Options.php
index 50ee1f249190f5dacdcae4f054483aa7d9b2d517..85273c4f9e35c7cb83ad0907c7e2b0b6a56a5c09 100644
--- a/module/VuFind/src/VuFind/Search/Base/Options.php
+++ b/module/VuFind/src/VuFind/Search/Base/Options.php
@@ -796,6 +796,17 @@ abstract class Options implements TranslatorAwareInterface
         return false;
     }
 
+    /**
+     * Return the route name for the versions search action. Returns false to cover
+     * unimplemented support.
+     *
+     * @return string|bool
+     */
+    public function getVersionsAction()
+    {
+        return false;
+    }
+
     /**
      * Does this search option support the cart/book bag?
      *
diff --git a/module/VuFind/src/VuFind/Search/RecommendListener.php b/module/VuFind/src/VuFind/Search/RecommendListener.php
index 9cbdbb2c9bc0dd8cc5817ab4823666edf991608e..8d68f03f75cd87af8eda4bd67975d9b0da82c009 100644
--- a/module/VuFind/src/VuFind/Search/RecommendListener.php
+++ b/module/VuFind/src/VuFind/Search/RecommendListener.php
@@ -150,6 +150,9 @@ class RecommendListener
                 // Break apart the setting into module name and extra parameters:
                 $current = explode(':', $current);
                 $module = array_shift($current);
+                if (empty($module)) {
+                    continue;
+                }
                 $config = implode(':', $current);
                 if (!$this->pluginManager->has($module)) {
                     throw new \Exception(
diff --git a/module/VuFind/src/VuFind/Search/Search2/Options.php b/module/VuFind/src/VuFind/Search/Search2/Options.php
index 7089af07a33f3667273befe117944dd37347658d..62747b48101f4033ae6d1328bbd582c35bd33794 100644
--- a/module/VuFind/src/VuFind/Search/Search2/Options.php
+++ b/module/VuFind/src/VuFind/Search/Search2/Options.php
@@ -50,6 +50,27 @@ class Options extends \VuFind\Search\Solr\Options
         parent::__construct($configLoader);
     }
 
+    /**
+     * Return the route name for the facet list action. Returns false to cover
+     * unimplemented support.
+     *
+     * @return string|bool
+     */
+    public function getFacetListAction()
+    {
+        return 'search2-facetlist';
+    }
+
+    /**
+     * Return the route name for the versions search action or false if disabled.
+     *
+     * @return string|bool
+     */
+    public function getVersionsAction()
+    {
+        return $this->displayRecordVersions ? 'search2-versions' : false;
+    }
+
     /**
      * Return the route name for the search results action.
      *
diff --git a/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php b/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php
index ab7356832611057c457a8da73e34e4dee3aafd26..a2cf5aa11b76a59fb978ddba69f8ab3bcdcdfb40 100644
--- a/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php
+++ b/module/VuFind/src/VuFind/Search/Solr/DeduplicationListener.php
@@ -9,7 +9,7 @@
  * PHP version 7
  *
  * Copyright (C) Villanova University 2013.
- * Copyright (C) The National Library of Finland 2013.
+ * Copyright (C) The National Library of Finland 2013-2020.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -137,7 +137,8 @@ class DeduplicationListener
         if ($backend === $this->backend) {
             $params = $event->getParam('params');
             $context = $event->getParam('context');
-            if ($params && in_array($context, ['search', 'similar', 'getids'])) {
+            $contexts = ['search', 'similar', 'getids', 'workExpressions'];
+            if ($params && in_array($context, $contexts)) {
                 // If deduplication is enabled, filter out merged child records,
                 // otherwise filter out dedup records.
                 if ($this->enabled && 'getids' !== $context
@@ -186,7 +187,8 @@ class DeduplicationListener
             return $event;
         }
         $context = $event->getParam('context');
-        if ($this->enabled && ($context == 'search' || $context == 'similar')) {
+        $contexts = ['search', 'similar', 'workExpressions'];
+        if ($this->enabled && in_array($context, $contexts)) {
             $this->fetchLocalRecords($event);
         }
         return $event;
diff --git a/module/VuFind/src/VuFind/Search/Solr/Options.php b/module/VuFind/src/VuFind/Search/Solr/Options.php
index de96e13cdb6affb583d951bb09b452b35fcb5998..8354835128a4df2174933b5ef3141b8e04bd51ef 100644
--- a/module/VuFind/src/VuFind/Search/Solr/Options.php
+++ b/module/VuFind/src/VuFind/Search/Solr/Options.php
@@ -70,6 +70,13 @@ class Options extends \VuFind\Search\Base\Options
      */
     protected $emptySearchRelevanceOverride = null;
 
+    /**
+     * Whether to display record versions
+     *
+     * @var bool
+     */
+    protected $displayRecordVersions = true;
+
     /**
      * Constructor
      *
@@ -114,6 +121,11 @@ class Options extends \VuFind\Search\Base\Options
             $this->defaultFilters = $searchSettings->General->default_filters
                 ->toArray();
         }
+        if (isset($searchSettings->General->display_versions)) {
+            $this->displayRecordVersions
+                = $searchSettings->General->display_versions;
+        }
+
         // Result limit:
         if (isset($searchSettings->General->result_limit)) {
             $this->resultLimit = $searchSettings->General->result_limit;
@@ -272,6 +284,16 @@ class Options extends \VuFind\Search\Base\Options
         return 'search-facetlist';
     }
 
+    /**
+     * Return the route name for the versions search action or false if disabled.
+     *
+     * @return string|bool
+     */
+    public function getVersionsAction()
+    {
+        return $this->displayRecordVersions ? 'search-versions' : false;
+    }
+
     /**
      * Get the relevance sort override for empty searches.
      *
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/RecordLink.php b/module/VuFind/src/VuFind/View/Helper/Root/RecordLink.php
index 8a3204f03d0b8ef4a11ef3c85e58b83f16aa0876..8d7b356bbeab01d85f1ab590273139682e74e03f 100644
--- a/module/VuFind/src/VuFind/View/Helper/Root/RecordLink.php
+++ b/module/VuFind/src/VuFind/View/Helper/Root/RecordLink.php
@@ -244,6 +244,32 @@ class RecordLink extends \Laminas\View\Helper\AbstractHelper
         return $escaper($url);
     }
 
+    /**
+     * Return search URL for all versions
+     *
+     * @param \VuFind\RecordDriver\AbstractBase $driver Record driver
+     *
+     * @return string
+     */
+    public function getVersionsSearchUrl($driver)
+    {
+        $route = $this->getVersionsActionForSource($driver->getSourceIdentifier());
+        if (false === $route) {
+            return '';
+        }
+
+        $urlParams = [
+            'id' => $driver->getUniqueID(),
+            'keys' => $driver->tryMethod('getWorkKeys', [], [])
+        ];
+
+        $urlHelper = $this->getView()->plugin('url');
+        $url = $urlHelper($route, [], ['query' => $urlParams]);
+        // Make sure everything is properly HTML encoded:
+        $escaper = $this->getView()->plugin('escapehtml');
+        return $escaper($url);
+    }
+
     /**
      * Given a record source ID, return the route name for searching its backend.
      *
@@ -256,4 +282,18 @@ class RecordLink extends \Laminas\View\Helper\AbstractHelper
         $optionsHelper = $this->getView()->plugin('searchOptions');
         return $optionsHelper->__invoke($source)->getSearchAction();
     }
+
+    /**
+     * Given a record source ID, return the route name for version search with its
+     * backend.
+     *
+     * @param string $source Record source identifier.
+     *
+     * @return string|bool
+     */
+    protected function getVersionsActionForSource($source)
+    {
+        $optionsHelper = $this->getView()->plugin('searchOptions');
+        return $optionsHelper->__invoke($source)->getVersionsAction();
+    }
 }
diff --git a/module/VuFind/src/VuFind/XSLT/Import/VuFindWorkKeys.php b/module/VuFind/src/VuFind/XSLT/Import/VuFindWorkKeys.php
new file mode 100644
index 0000000000000000000000000000000000000000..84664286e8086578aa47be5435f914ec547ebc91
--- /dev/null
+++ b/module/VuFind/src/VuFind/XSLT/Import/VuFindWorkKeys.php
@@ -0,0 +1,160 @@
+<?php
+/**
+ * XSLT importer support methods for work key generation.
+ *
+ * PHP version 7
+ *
+ * Copyright (c) Demian Katz 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Import_Tools
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/indexing Wiki
+ */
+namespace VuFind\XSLT\Import;
+
+use DOMDocument;
+use Normalizer;
+
+/**
+ * XSLT importer support methods for work key generation.
+ *
+ * @category VuFind
+ * @package  Import_Tools
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/indexing Wiki
+ */
+class VuFindWorkKeys
+{
+    /**
+     * Get all work identification keys for the record.
+     *
+     * @param Iterable $uniformTitles       Uniform title(s) for the work
+     * @param Iterable $titles              Other title(s) for the work
+     * @param Iterable $trimmedTitles       Title(s) with leading articles, etc.,
+     * removed
+     * @param Iterable $authors             Author(s) for the work
+     * @param string   $includeRegEx        Regular expression defining characters to
+     * keep
+     * @param string   $excludeRegEx        Regular expression defining characters to
+     * remove
+     * @param string   $transliteratorRules Optional ICU transliteration rules to be
+     * applied before the include and exclude regex's. See
+     * https://unicode-org.github.io/icu/userguide/transforms/general/
+     * #icu-transliterators for more information on the transliteration rules.
+     *
+     * @return DOMDocument
+     */
+    public static function getWorkKeys($uniformTitles, $titles, $trimmedTitles,
+        $authors, $includeRegEx = '', $excludeRegEx = '', $transliteratorRules = ''
+    ) {
+        $transliterator = $transliteratorRules
+            ? \Transliterator::createFromRules(
+                $transliteratorRules, \Transliterator::FORWARD
+            ) : null;
+
+        $dom = new DOMDocument('1.0', 'utf-8');
+
+        $uniformTitles = is_iterable($uniformTitles)
+            ? $uniformTitles : (array)$uniformTitles;
+        foreach ($uniformTitles as $uniformTitle) {
+            $normalizedTitle = self::normalize(
+                $uniformTitle, $includeRegEx, $excludeRegEx, $transliterator
+            );
+            if (!empty($normalizedTitle)) {
+                $element = $dom->createElement('workKey', 'UT ' . $normalizedTitle);
+                $dom->appendChild($element);
+            }
+        }
+
+        // Exit early if there are no authors, since we can't make author/title keys:
+        $authors = is_iterable($authors) ? $authors : (array)$authors;
+        if (empty($authors)) {
+            return $dom;
+        }
+        $titles = $titles instanceof \Traversable
+            ? iterator_to_array($titles) : (array)$titles;
+        $trimmedTitles = $trimmedTitles instanceof \Traversable
+            ? iterator_to_array($trimmedTitles) : (array)$trimmedTitles;
+        $normalizedTitles = [];
+        foreach (array_merge($titles, $trimmedTitles) as $title) {
+            $normalizedTitle = self::normalize(
+                $title, $includeRegEx, $excludeRegEx, $transliterator
+            );
+            if (empty($normalizedTitle)                          // skip empties
+                || in_array($normalizedTitle, $normalizedTitles) // avoid dupes
+            ) {
+                continue;
+            }
+            $normalizedTitles[] = $normalizedTitle;
+            foreach ($authors as $author) {
+                $normalizedAuthor = self::normalize(
+                    $author, $includeRegEx, $excludeRegEx, $transliterator
+                );
+                if (!empty($author)) {
+                    $key = 'AT ' . $normalizedAuthor . ' ' . $normalizedTitle;
+                    $element = $dom->createElement('workKey', $key);
+                    $dom->appendChild($element);
+                }
+            }
+        }
+
+        return $dom;
+    }
+
+    /**
+     * Force a value to a string, even if it's a DOMElement.
+     *
+     * @param string|DOMElement $string String to normalize
+     *
+     * @return string
+     */
+    protected static function deDom($string): string
+    {
+        return $string->textContent ?? (string)$string;
+    }
+
+    /**
+     * Create a key string.
+     *
+     * @param string|DOMElement $rawString      String to normalize
+     * @param string            $includeRegEx   Regular expression defining
+     * characters to keep
+     * @param string            $excludeRegEx   Regular expression defining
+     * characters to remove
+     * @param \Transliterator   $transliterator Transliterator
+     *
+     * @return string
+     */
+    protected static function normalize($rawString, $includeRegEx, $excludeRegEx,
+        $transliterator
+    ) {
+        // Handle strings and/or DOM elements:
+        $string = self::deDom($rawString);
+        $normalized = $transliterator ? $transliterator->transliterate($string)
+            : Normalizer::normalize($string, Normalizer::FORM_KC);
+        if (!empty($includeRegEx)) {
+            preg_match_all($includeRegEx, $normalized, $matches);
+            $normalized = implode($matches[0] ?? []);
+        }
+        if (!empty($excludeRegEx)) {
+            $normalized = preg_replace($excludeRegEx, '', $normalized);
+        }
+        return substr(strtolower($normalized), 0, 255);
+    }
+}
diff --git a/module/VuFind/src/VuFindTest/Unit/MinkTestCase.php b/module/VuFind/src/VuFindTest/Unit/MinkTestCase.php
index 2997f9054bd2745760846b00f1e9595f0e9ef912..7fc5d31cc47d50c4a636e19d94034f56bce18790 100644
--- a/module/VuFind/src/VuFindTest/Unit/MinkTestCase.php
+++ b/module/VuFind/src/VuFindTest/Unit/MinkTestCase.php
@@ -342,13 +342,14 @@ abstract class MinkTestCase extends DbTestCase
      *
      * @param string $query   Search term(s)
      * @param string $handler Search type (optional)
+     * @param string $path    Path to use as search starting point (optional)
      *
      * @return \Behat\Mink\Element\Element
      */
-    protected function performSearch($query, $handler = null)
+    protected function performSearch($query, $handler = null, $path = '/Search')
     {
         $session = $this->getMinkSession();
-        $session->visit($this->getVuFindUrl() . '/Search/Home');
+        $session->visit($this->getVuFindUrl() . $path);
         $page = $session->getPage();
         $this->findCss($page, '#searchForm_lookfor')->setValue($query);
         if ($handler) {
diff --git a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/RecordVersionsTest.php b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/RecordVersionsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..335975211973a9520059adc8a2a41f7e44ff5bd4
--- /dev/null
+++ b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/RecordVersionsTest.php
@@ -0,0 +1,175 @@
+<?php
+/**
+ * Record versions test class.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2021.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFindTest\Mink;
+
+/**
+ * Record versions test class.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ * @retry    4
+ */
+class RecordVersionsTest extends \VuFindTest\Unit\MinkTestCase
+{
+    use \VuFindTest\Unit\AutoRetryTrait;
+
+    /**
+     * Standard setup method.
+     *
+     * @return void
+     */
+    public function setUp(): void
+    {
+        // Give up if we're not running in CI:
+        if (!$this->continuousIntegrationRunning()) {
+            $this->markTestSkipped('Continuous integration not running.');
+            return;
+        }
+    }
+
+    /**
+     * Run test procedure for record versions.
+     *
+     * @param string $path Path to search from.
+     *
+     * @return void
+     */
+    protected function runVersionsTest($path)
+    {
+        // Search for an item known to have other versions in test data:
+        $page = $this->performSearch('id:0001732009-0', null, $path);
+
+        // Confirm that "other versions" link exists:
+        $this->assertEquals(
+            'Show other versions (3)',
+            $this->findCss($page, 'div.record-versions a')->getText()
+        );
+
+        // Click on the "other versions" link:
+        $this->clickCss($page, 'div.record-versions a');
+        $this->snooze();
+
+        // Confirm that we've landed on an other versions tab:
+        $this->assertEquals(
+            'Other Versions (3)',
+            $this->findCss($page, 'li.record-tab.active')->getText()
+        );
+
+        // Click the "see all versions" link:
+        $this->clickCss($page, 'div.search-controls a.more-link');
+        $this->snooze();
+
+        // Confirm that all four versions are now visible in the versions display:
+        $this->assertEquals(
+            'Versions - The collected letters of Thomas and Jane Welsh Carlyle :',
+            $this->findCss($page, 'ul.breadcrumb li.active')->getText()
+        );
+        $results = $page->findAll('css', '.result');
+        $this->assertEquals(4, count($results));
+    }
+
+    /**
+     * Test accessing a record with multiple versions.
+     *
+     * @return void
+     */
+    public function testVersions()
+    {
+        $this->runVersionsTest('/Search');
+    }
+
+    /**
+     * Test accessing a record with multiple versions via secondary search.
+     *
+     * @return void
+     */
+    public function testVersionsInSearch2()
+    {
+        $this->runVersionsTest('/Search2');
+    }
+
+    /**
+     * Confirm that links operate differently when the record versions tab is
+     * disabled but other version settings are enabled.
+     *
+     * @return void
+     */
+    public function testDisabledVersionsTab()
+    {
+        // Disable versions tab:
+        $extraConfigs['RecordTabs']['VuFind\RecordDriver\SolrMarc'] = [
+            'tabs[Versions]' => false
+        ];
+        $this->changeConfigs($extraConfigs);
+        // Search for an item known to have other versions in test data:
+        $page = $this->performSearch('id:0001732009-0', null, '/Search');
+
+        // Confirm that "all versions" link exists:
+        $this->assertEquals(
+            'Show all versions (4)',
+            $this->findCss($page, 'div.record-versions a')->getText()
+        );
+
+        // Click on the "all versions" link:
+        $this->clickCss($page, 'div.record-versions a');
+        $this->snooze();
+
+        // Confirm that we have jumped directly to the "show all versions" screen
+        // and that all four versions are now visible in the versions display:
+        $this->assertEquals(
+            'Versions - The collected letters of Thomas and Jane Welsh Carlyle :',
+            $this->findCss($page, 'ul.breadcrumb li.active')->getText()
+        );
+        $results = $page->findAll('css', '.result');
+        $this->assertEquals(4, count($results));
+    }
+
+    /**
+     * Confirm that version controls do not appear in search results when the setting
+     * is disabled.
+     *
+     * @return void
+     */
+    public function testDisabledVersions()
+    {
+        // Disable versions:
+        $extraConfigs['searches']['General'] = ['display_versions' => false];
+        $this->changeConfigs($extraConfigs);
+
+        // Search for an item known to have other versions in test data:
+        $page = $this->performSearch('id:0001732009-0');
+
+        // Click on the "other versions" link:
+        $this->assertEquals(
+            0, count($page->findAll('css', 'div.record-versions a'))
+        );
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php
index c95afffbafc87683ed8ad4ef80628b0e22e5b80c..cbf0f74b97bed34e7537344f8878d777b411dcf7 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php
@@ -133,7 +133,8 @@ class UpgradeTest extends \VuFindTest\Unit\TestCase
         $this->assertEquals(
             [
                 'Author' => ['AuthorFacets', 'SpellingSuggestions'],
-                'CallNumber' => ['TopFacets:ResultsTop']
+                'CallNumber' => ['TopFacets:ResultsTop'],
+                'WorkKeys' => ['']
             ],
             $results['searches.ini']['TopRecommendations']
         );
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/XSLT/Import/VuFindWorkKeysTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/XSLT/Import/VuFindWorkKeysTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..015300b81c5d7395892d3ef9febdda419e340af7
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/XSLT/Import/VuFindWorkKeysTest.php
@@ -0,0 +1,178 @@
+<?php
+/**
+ * XSLT helper tests for VuFindWorkKeys.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+namespace VuFindTest\XSLT\Import;
+
+use VuFind\XSLT\Import\VuFindWorkKeys;
+
+/**
+ * XSLT helper tests for VuFindWorkKeys.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:testing:unit_tests Wiki
+ */
+class VuFindWorkKeysTest extends \VuFindTest\Unit\TestCase
+{
+    /**
+     * Test the work keys helper with an include regex.
+     *
+     * @return void
+     */
+    public function testGetWorkKeysWithIncludeRegEx()
+    {
+        $expected = '<?xml version="1.0" encoding="utf-8"?>'
+            . "\n<workKey>UT uniformtitle</workKey>\n"
+            . "<workKey>AT author1 thenonuniformtitle</workKey>\n"
+            . "<workKey>AT author2 thenonuniformtitle</workKey>\n"
+            . "<workKey>AT author1 nonuniformtitle</workKey>\n"
+            . "<workKey>AT author2 nonuniformtitle</workKey>\n";
+
+        $result = VuFindWorkKeys::getWorkKeys(
+            'UNIFORM title.',
+            ['The nonUniform Title'],
+            ['nonUniform Title'],
+            ['AUTHOR 1', 'author 2'],
+            '/([0-9A-Za-z]+)/',
+            ''
+        );
+        $this->assertEquals(
+            $expected,
+            simplexml_import_dom($result)->asXml()
+        );
+    }
+
+    /**
+     * Test the work keys helper with an include regex and trimmed titles === titles.
+     *
+     * @return void
+     */
+    public function testGetWorkKeysWithIncludeRegExAndDuplicateTitles()
+    {
+        $expected = '<?xml version="1.0" encoding="utf-8"?>'
+            . "\n<workKey>UT uniformtitle</workKey>\n"
+            . "<workKey>AT author1 nonuniformtitle</workKey>\n"
+            . "<workKey>AT author2 nonuniformtitle</workKey>\n";
+
+        $result = VuFindWorkKeys::getWorkKeys(
+            'UNIFORM title.',
+            ['nonUniform Title'],
+            ['nonUniform Title'],
+            ['AUTHOR 1', 'author 2'],
+            '/([0-9A-Za-z]+)/',
+            ''
+        );
+        $this->assertEquals(
+            $expected,
+            simplexml_import_dom($result)->asXml()
+        );
+    }
+
+    /**
+     * Test the work keys helper with an exclude regex.
+     *
+     * @return void
+     */
+    public function testGetWorkKeysWithExcludeRegEx()
+    {
+        $expected = '<?xml version="1.0" encoding="utf-8"?>'
+            . "\n<workKey>UT unformttle</workKey>\n"
+            . "<workKey>AT author1 thenonunformttle</workKey>\n"
+            . "<workKey>AT author2 thenonunformttle</workKey>\n"
+            . "<workKey>AT author1 nonunformttle</workKey>\n"
+            . "<workKey>AT author2 nonunformttle</workKey>\n";
+
+        $result = VuFindWorkKeys::getWorkKeys(
+            'UNIFORM title',
+            ['The nonUniform Title'],
+            ['nonUniform Title'],
+            ['AUTHOR 1', 'author 2'],
+            '',
+            '/[i ]/i' // arbitrarily exclude spaces and i's for testing purposes
+        );
+        $this->assertEquals(
+            $expected,
+            simplexml_import_dom($result)->asXml()
+        );
+    }
+
+    /**
+     * Test the work keys helper with an ICU tranliteration.
+     *
+     * @return void
+     */
+    public function testGetWorkKeysWithTransliteration()
+    {
+        $expected = '<?xml version="1.0" encoding="utf-8"?>'
+            . "\n<workKey>UT uniformtitlea</workKey>\n"
+            . "<workKey>AT author1 thenonuniformtitle</workKey>\n"
+            . "<workKey>AT author2 thenonuniformtitle</workKey>\n"
+            . "<workKey>AT author1 nonuniformtitle</workKey>\n"
+            . "<workKey>AT author2 nonuniformtitle</workKey>\n";
+
+        $result = VuFindWorkKeys::getWorkKeys(
+            'UNIFORM title +Ã…',
+            ['The nonUniform  Titlë'],
+            ['nonUniform  Titlë'],
+            ['AUTHOR * 1', 'author - 2'],
+            '',
+            '',
+            ':: NFD; :: lower; :: Latin; :: [^[:letter:] [:number:]] Remove; :: NFKC;'
+        );
+        $this->assertEquals(
+            $expected,
+            simplexml_import_dom($result)->asXml()
+        );
+    }
+
+    /**
+     * Test the work keys helper with an ICU tranliteration.
+     *
+     * @return void
+     */
+    public function testGetWorkKeysWithoutAuthors()
+    {
+        $expected = '<?xml version="1.0" encoding="utf-8"?>'
+            . "\n<workKey>UT uniformtitlea</workKey>\n";
+
+        $result = VuFindWorkKeys::getWorkKeys(
+            'UNIFORM title +Ã…',
+            ['The nonUniform  Titlë'],
+            ['nonUniform  Titlë'],
+            [],
+            '',
+            '',
+            ':: NFD; :: lower; :: Latin; :: [^[:letter:] [:number:]] Remove; :: NFKC;'
+        );
+        $this->assertEquals(
+            $expected,
+            simplexml_import_dom($result)->asXml()
+        );
+    }
+}
diff --git a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php
index e48b8567a21d26365e1ee823a493ad0daf2544ce..deb257f80e2893a26d4a89b73e3272c5f83186f3 100644
--- a/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php
+++ b/module/VuFindSearch/src/VuFindSearch/Backend/Solr/Backend.php
@@ -39,6 +39,7 @@ use VuFindSearch\Feature\RandomInterface;
 
 use VuFindSearch\Feature\RetrieveBatchInterface;
 use VuFindSearch\Feature\SimilarInterface;
+use VuFindSearch\Feature\WorkExpressionsInterface;
 use VuFindSearch\ParamBag;
 use VuFindSearch\Query\AbstractQuery;
 
@@ -58,7 +59,7 @@ use VuFindSearch\Response\RecordCollectionInterface;
  */
 class Backend extends AbstractBackend
     implements SimilarInterface, RetrieveBatchInterface, RandomInterface,
-    GetIdsInterface
+    GetIdsInterface, WorkExpressionsInterface
 {
     /**
      * Limit for records per query in a batch retrieval.
@@ -351,6 +352,41 @@ class Backend extends AbstractBackend
         return $this->deserialize($response);
     }
 
+    /**
+     * Return work expressions.
+     *
+     * @param string   $id            Id of record to compare with
+     * @param array    $workKeys      Work identification keys
+     * @param ParamBag $defaultParams Search backend parameters
+     *
+     * @return RecordCollectionInterface
+     */
+    public function workExpressions($id, $workKeys, ParamBag $defaultParams = null)
+    {
+        $params = $defaultParams ? clone $defaultParams
+            : new \VuFindSearch\ParamBag();
+        $this->injectResponseWriter($params);
+        $query = [];
+        foreach ($workKeys as $key) {
+            $key = addcslashes($key, '+-&|!(){}[]^"~*?:\\/');
+            $query[] = "work_keys_str_mv:(\"$key\")";
+        }
+        $params->set('q', implode(' OR ', $query));
+        if ($id) {
+            $params->add('fq', sprintf('-id:"%s"', addcslashes($id, '"')));
+        }
+        if (!$params->hasParam('rows')) {
+            $params->add('rows', 100);
+        }
+        if (!$params->hasParam('sort')) {
+            $params->add('sort', 'publishDateSort desc, title_sort asc');
+        }
+        $response = $this->connector->search($params);
+        $collection = $this->createRecordCollection($response);
+        $this->injectSourceIdentifier($collection);
+        return $collection;
+    }
+
     /**
      * Set the query builder.
      *
diff --git a/module/VuFindSearch/src/VuFindSearch/Feature/WorkExpressionsInterface.php b/module/VuFindSearch/src/VuFindSearch/Feature/WorkExpressionsInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..693510b7c5cd3beda823101b869f9d637d970265
--- /dev/null
+++ b/module/VuFindSearch/src/VuFindSearch/Feature/WorkExpressionsInterface.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * Work expressions feature interface definition.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) The National Library of Finland 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Search
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org
+ */
+namespace VuFindSearch\Feature;
+
+use VuFindSearch\ParamBag;
+
+/**
+ * Work expressions feature interface definition.
+ *
+ * @category VuFind
+ * @package  Search
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org
+ */
+interface WorkExpressionsInterface
+{
+    /**
+     * Return work expressions.
+     *
+     * @param string   $id            Id of record to compare with
+     * @param array    $workKeys      Work identification keys
+     * @param ParamBag $defaultParams Search backend parameters
+     *
+     * @return RecordCollectionInterface
+     */
+    public function workExpressions($id, $workKeys, ParamBag $defaultParams = null);
+}
diff --git a/module/VuFindSearch/src/VuFindSearch/Service.php b/module/VuFindSearch/src/VuFindSearch/Service.php
index 3680fbd11e81eca7fece0313910dacea33aa2888..bfac972b7a41c0a01634fedfd3ecae12f22a9650 100644
--- a/module/VuFindSearch/src/VuFindSearch/Service.php
+++ b/module/VuFindSearch/src/VuFindSearch/Service.php
@@ -6,6 +6,7 @@
  * PHP version 7
  *
  * Copyright (C) Villanova University 2010.
+ * Copyright (C) The National Library of Finland 2019.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -23,6 +24,7 @@
  * @category VuFind
  * @package  Search
  * @author   David Maus <maus@hab.de>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org
  */
@@ -44,6 +46,7 @@ use VuFindSearch\Response\RecordCollectionInterface;
  * @category VuFind
  * @package  Search
  * @author   David Maus <maus@hab.de>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org
  */
@@ -347,6 +350,50 @@ class Service
         return $response;
     }
 
+    /**
+     * Return records for work expressions.
+     *
+     * @param string   $backend  Search backend identifier
+     * @param string   $id       Id of record to compare with
+     * @param array    $workKeys Work identification keys (optional; retrieved from
+     * the record to compare with if not specified)
+     * @param ParamBag $params   Search backend parameters
+     *
+     * @return RecordCollectionInterface
+     */
+    public function workExpressions($backend, $id, $workKeys = null,
+        ParamBag $params = null
+    ) {
+        $params  = $params ?: new \VufindSearch\ParamBag();
+        $context = __FUNCTION__;
+        $args = compact('backend', 'id', 'params', 'context', 'workKeys');
+        $backendInstance = $this->resolve($backend, $args);
+        $args['backend_instance'] = $backendInstance;
+
+        $this->triggerPre($backendInstance, $args);
+        try {
+            if (!($backendInstance instanceof Feature\WorkExpressionsInterface)) {
+                throw new BackendException(
+                    "$backend does not support workExpressions()"
+                );
+            }
+            if (empty($args['workKeys'])) {
+                $records = $backendInstance->retrieve($id)->getRecords();
+                if (!empty($records[0])) {
+                    $fields = $records[0]->getRawData();
+                    $args['workKeys'] = $fields['work_keys_str_mv'] ?? [];
+                }
+            }
+            $response = $backendInstance
+                ->workExpressions($id, $args['workKeys'], $params);
+        } catch (BackendException $e) {
+            $this->triggerError($e, $args);
+            throw $e;
+        }
+        $this->triggerPost($response, $args);
+        return $response;
+    }
+
     /**
      * Set EventManager instance.
      *
diff --git a/themes/bootstrap3/js/combined-search.js b/themes/bootstrap3/js/combined-search.js
index 13b3508406465b71fd09cbe7271d01c968f688b9..9a1871f3ea890e2aeb13e71f2f89201688f81b17 100644
--- a/themes/bootstrap3/js/combined-search.js
+++ b/themes/bootstrap3/js/combined-search.js
@@ -1,20 +1,26 @@
 /*global VuFind, checkSaveStatuses, setupQRCodeLinks */
 VuFind.combinedSearch = (function CombinedSearch() {
-  var init = function init(container, url) {
+  function initResultScripts(container) {
+    VuFind.openurl.init(container);
+    VuFind.itemStatuses.init(container);
+    checkSaveStatuses(container);
+    setupQRCodeLinks(container);
+    VuFind.recordVersions.init(container);
+  }
+
+  function init(container, url) {
     container.load(url, '', function containerLoad(responseText) {
       if (responseText.length === 0) {
         container.hide();
       } else {
-        VuFind.openurl.init(container);
-        VuFind.itemStatuses.init(container);
-        checkSaveStatuses(container);
-        setupQRCodeLinks(container);
+        initResultScripts(container);
       }
     });
-  };
+  }
 
   var my = {
-    init: init
+    init: init,
+    initResultScripts: initResultScripts
   };
 
   return my;
diff --git a/themes/bootstrap3/js/record_versions.js b/themes/bootstrap3/js/record_versions.js
new file mode 100644
index 0000000000000000000000000000000000000000..8295b136266b20599e1ebc02ad1062cb19cf580a
--- /dev/null
+++ b/themes/bootstrap3/js/record_versions.js
@@ -0,0 +1,57 @@
+/*global Hunt, VuFind */
+
+VuFind.register('recordVersions', function recordVersions() {
+  function checkRecordVersions(_container) {
+    var container = typeof _container === 'undefined' ? $(document) : $(_container);
+
+    var elements = container.hasClass('record-versions') && container.hasClass('ajax')
+      ? container : container.find('.record-versions.ajax');
+    elements.each(function checkVersions() {
+      var $elem = $(this);
+      if ($elem.hasClass('loaded')) {
+        return;
+      }
+      $elem.addClass('loaded');
+      $elem.removeClass('hidden');
+      $elem.append('<span class="js-load">' + VuFind.translate('loading') + '...</span>');
+      var $item = $(this).parents('.result');
+      var id = $item.find('.hiddenId')[0].value;
+      var source = $item.find('.hiddenSource')[0].value;
+      $.getJSON(
+        VuFind.path + '/AJAX/JSON',
+        {
+          method: 'getRecordVersions',
+          id: id,
+          source: source
+        }
+      )
+        .done(function onGetVersionsDone(response) {
+          if (response.data.length > 0) {
+            $elem.html(response.data);
+          } else {
+            $elem.text('');
+          }
+        })
+        .fail(function onGetVersionsFail() {
+          $elem.text(VuFind.translate('error_occurred'));
+        });
+    });
+  }
+
+  function init(_container) {
+    if (typeof Hunt === 'undefined') {
+      checkRecordVersions(_container);
+    } else {
+      var container = typeof _container === 'undefined'
+        ? document.body
+        : _container;
+      new Hunt(
+        $(container).find('.record-versions.ajax').toArray(),
+        { enter: checkRecordVersions }
+      );
+    }
+  }
+
+  return { init: init, check: checkRecordVersions };
+});
+
diff --git a/themes/bootstrap3/less/components/search.less b/themes/bootstrap3/less/components/search.less
index 43c31909e92ca16ddfa45c1af7d3ae1bdb27fcb2..e3948ef8c1486a91ca7cae34d7ad9b9dbfedfe60 100644
--- a/themes/bootstrap3/less/components/search.less
+++ b/themes/bootstrap3/less/components/search.less
@@ -49,6 +49,17 @@ header .container.navbar { margin-bottom: 0; }
   .search-controls { text-align: right; }
 }
 
+.versions-tab {
+  .search-controls {
+    padding-top: 0.5rem;
+    padding-left: 1rem;
+    text-align: left;
+  }
+  .search-header {
+    .search-stats { flex-grow: 0; }
+  }
+}
+
 .record-nav,
 .bulkActionButtons {
   .clearfix();
diff --git a/themes/bootstrap3/scss/components/search.scss b/themes/bootstrap3/scss/components/search.scss
index b2377416c21c9dbc41d6368ef997ebaa40f8ab94..f5bf702e7258128dd5aa818b236930890028a445 100644
--- a/themes/bootstrap3/scss/components/search.scss
+++ b/themes/bootstrap3/scss/components/search.scss
@@ -49,6 +49,17 @@ header .container.navbar { margin-bottom: 0; }
   .search-controls { text-align: right; }
 }
 
+.versions-tab {
+  .search-controls {
+    padding-top: 0.5rem;
+    padding-left: 1rem;
+    text-align: left;
+  }
+  .search-header {
+    .search-stats { flex-grow: 0; }
+  }
+}
+
 .record-nav,
 .bulkActionButtons {
   @include clearfix();
diff --git a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/core.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/core.phtml
index 2b3d52454692becde21c978b669b097ddfab28f9..f03f1c355908841e4a81972d417cead92f2a7b93 100644
--- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/core.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/core.phtml
@@ -34,6 +34,15 @@
 
     <h1 property="name"><?=$this->escapeHtml($this->driver->getShortTitle() . ' ' . $this->driver->getSubtitle() . ' ' . $this->driver->getTitleSection())?></h1>
 
+    <?php if(!empty($this->extraControls)): ?>
+      <?=$this->extraControls['actionControls'] ?? ''?>
+      <?=$this->extraControls['availabilityInfo'] ?? ''?>
+    <?php endif; ?>
+
+    <?php if ($this->searchOptions($this->driver->getSourceIdentifier())->getVersionsAction()): ?>
+      <?=$this->record($this->driver)->renderTemplate('versions-link.phtml')?>
+    <?php endif; ?>
+
     <?php $summary = $this->driver->getSummary(); $summary = isset($summary[0]) ? $this->escapeHtml($summary[0]) : false; ?>
     <?php if ($summary): ?>
       <p><?=$this->truncate($summary, 300)?></p>
diff --git a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml
index 354fed19b0fc8f7e1299637cbbc2a73c066e668d..99ec3394e1c41044212e777bc540d690c57a64b6 100644
--- a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml
+++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/result-list.phtml
@@ -20,7 +20,7 @@
   <div class="media-body">
     <div class="result-body">
       <div>
-        <a href="<?=$this->recordLink()->getUrl($this->driver)?>" class="title getFull" data-view="<?=$this->params->getOptions()->getListViewOption() ?>">
+        <a href="<?=$this->recordLink()->getUrl($this->driver)?>" class="title getFull" data-view="<?=isset($this->params) ? $this->params->getOptions()->getListViewOption() : 'list' ?>">
           <?=$this->record($this->driver)->getTitleHtml()?>
         </a>
       </div>
@@ -97,6 +97,10 @@
         </div>
       <?php endif; ?>
 
+      <?php if ($this->driver->tryMethod('getWorkKeys') && $this->searchOptions($this->driver->getSourceIdentifier())->getVersionsAction()): ?>
+        <div class="record-versions ajax"></div>
+      <?php endif; ?>
+
       <div class="callnumAndLocation ajax-availability hidden">
         <?php if ($this->driver->supportsAjaxStatus()): ?>
           <strong class="hideIfDetailed"><?=$this->transEsc('Call Number')?>:</strong>
@@ -172,7 +176,7 @@
         </span>
       <?php endif; ?>
 
-      <?php if ($this->cart()->isActiveInSearch() && $this->params->getOptions()->supportsCart() && $this->cart()->isActive()): ?>
+      <?php if ($this->cart()->isActiveInSearch() && isset($this->params) && $this->params->getOptions()->supportsCart() && $this->cart()->isActive()): ?>
         <?=$this->render('record/cart-buttons.phtml', ['id' => $this->driver->getUniqueId(), 'source' => $this->driver->getSourceIdentifier()]); ?><br/>
       <?php endif; ?>
 
diff --git a/themes/bootstrap3/templates/RecordDriver/DefaultRecord/versions-link.phtml b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/versions-link.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..4db0dbad6852969126e14429ed7390280bf40980
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordDriver/DefaultRecord/versions-link.phtml
@@ -0,0 +1,20 @@
+<?php
+  $versions = $this->driver->tryMethod('getOtherVersionCount');
+  if (!$versions) {
+    return;
+  }
+  if (isset($this->tabs['Versions'])) {
+    $url = !empty($this->full) ? $this->escapeHtmlAttr($this->recordLink()->getTabUrl($this->driver, 'Versions')) . '#tabnav' : '#versions';
+    $translationKey = 'other_versions_link';
+  } else {
+    $url = $this->recordLink()->getVersionsSearchUrl($this->driver);
+    $translationKey = 'other_versions_search_link';
+    // Search results include this record, so increase the count
+    $versions++;
+  }
+?>
+<div class="record-versions">
+  <a href="<?=$url?>">
+    <i class="fa fa-arrow-right" aria-hidden="true"></i> <?=$this->transEsc($translationKey, ['%%count%%' => $versions])?>
+  </a>
+</div>
diff --git a/themes/bootstrap3/templates/RecordTab/versions.phtml b/themes/bootstrap3/templates/RecordTab/versions.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..c0cf3c1b5d5b1d83b31a871234e335ab14e39596
--- /dev/null
+++ b/themes/bootstrap3/templates/RecordTab/versions.phtml
@@ -0,0 +1,50 @@
+<?php
+  $results = $this->driver->tryMethod('getVersions', [false, 20]);
+  $view = 'list';
+  $this->render('search/results-scripts.phtml', ['displayVersions' => true]);
+?>
+<div class="results result-view-<?=$this->escapeHtmlAttr($view)?>">
+  <nav class="search-header hidden-print">
+    <div class="search-stats">
+      <?=$this->translate(
+        'showing_results_of_html', [
+          '%%start%%' => 1,
+          '%%end%%' => count($results),
+          '%%total%%' => $this->localizedNumber($results->getTotal())
+        ]
+      );
+      ?>
+    </div>
+    <div class="search-controls">
+      <a class="more-link" href="<?=$this->recordLink()->getVersionsSearchUrl($this->driver)?>" rel="nofollow">
+        <i class="fa fa-arrow-right" aria-hidden="true"></i> <?=$this->transEsc('other_versions_search_link', ['%%count%%' => $results->getTotal() + 1])?>
+      </a>
+    </div>
+  </nav>
+  <?php $recordNumber = $results->getOffset(); ?>
+  <?php foreach ($results as $current): ?>
+    <div id="result<?=++$recordNumber ?>" class="result clearfix<?=$current->supportsAjaxStatus() ? ' ajaxItem' : ''?>">
+      <span class="sr-only"><?=$this->transEsc('Search Result');?> <?=$recordNumber ?></span>
+      <?=$this->record($current)->getSearchResult($view)?>
+    </div>
+  <?php endforeach; ?>
+  <div class="search-tools hidden-print">
+    <div class="search-controls">
+      <a class="more-link" href="<?=$this->recordLink()->getVersionsSearchUrl($this->driver)?>" rel="nofollow">
+        <i class="fa fa-arrow-right" aria-hidden="true"></i> <?=$this->transEsc('other_versions_search_link', ['%%count%%' => $results->getTotal() + 1])?>
+      </a>
+    </div>
+  </div>
+</div>
+
+<?php
+// Add any translations added when processing the search results:
+$translations = $this->jsTranslations()->getJSON();
+// Use combinedSearch.initResultScripts to avoid duplicating the list of scripts
+// required for results loaded via AJAX:
+$script = <<<JS
+VuFind.addTranslations($translations);
+VuFind.combinedSearch.initResultScripts($('.results'));
+JS;
+?>
+<?=$this->inlineScript(\Laminas\View\Helper\HeadScript::SCRIPT, $script, 'SET'); ?>
diff --git a/themes/bootstrap3/templates/combined/results.phtml b/themes/bootstrap3/templates/combined/results.phtml
index 0dbb615ba7eab7d87c02fd101a8f17d6eb51ff82..c488a7226ba6b29feb17beb08978c5bee65d8fa2 100644
--- a/themes/bootstrap3/templates/combined/results.phtml
+++ b/themes/bootstrap3/templates/combined/results.phtml
@@ -34,10 +34,18 @@
   // Enable bulk options if appropriate:
   $this->showCheckboxes = $this->showCartControls || $this->showBulkOptions;
 
+  // Check if any results have version support enabled:
+  $displayVersions = false;
+  foreach (array_keys($this->combinedResults) as $configSection) {
+    list($searchClassId) = explode(':', $configSection);
+    if ($this->searchOptions($searchClassId)->getVersionsAction()) {
+      $displayVersions = true;
+      break;
+    }
+  }
+
   // Load Javascript dependencies into header:
-  $this->headScript()->appendFile("vendor/hunt.min.js");
-  $this->headScript()->appendFile("check_item_statuses.js");
-  $this->headScript()->appendFile("check_save_statuses.js");
+  $this->render('search/results-scripts.phtml', compact('displayVersions'));
   $this->headScript()->appendFile("combined-search.js");
   // Style
   $this->headLink()->appendStylesheet('combined-search.css');
diff --git a/themes/bootstrap3/templates/search/controls/results-tools.phtml b/themes/bootstrap3/templates/search/controls/results-tools.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..a46e3805e1692daa1b97482814f6b236608ff0e8
--- /dev/null
+++ b/themes/bootstrap3/templates/search/controls/results-tools.phtml
@@ -0,0 +1,16 @@
+<div class="searchtools hidden-print">
+  <strong><?=$this->transEsc('Search Tools')?>:</strong>
+  <a href="<?=$this->results->getUrlQuery()->setViewParam('rss')?>"><i class="fa fa-bell" aria-hidden="true"></i> <?=$this->transEsc('Get RSS Feed')?></a>
+  &mdash;
+  <a href="<?=$this->url('search-email')?>" class="mailSearch" data-lightbox id="mailSearch<?=$this->escapeHtmlAttr($this->results->getSearchId())?>">
+    <i class="fa fa-envelope" aria-hidden="true"></i> <?=$this->transEsc('Email this Search')?>
+  </a>
+  <?php if ($this->accountCapabilities()->getSavedSearchSetting() === 'enabled' && is_numeric($this->results->getSearchId())): ?>
+    &mdash;
+    <?php if ($this->results->isSavedSearch()): ?>
+      <a href="<?=$this->url('myresearch-savesearch')?>?delete=<?=urlencode($this->results->getSearchId())?>"><i class="fa fa-remove" aria-hidden="true"></i> <?=$this->transEsc('save_search_remove')?></a>
+    <?php else: ?>
+      <a href="<?=$this->url('myresearch-savesearch')?>?save=<?=urlencode($this->results->getSearchId())?>"><i class="fa fa-save" aria-hidden="true"></i> <?=$this->transEsc('save_search')?></a>
+    <?php endif; ?>
+  <?php endif; ?>
+</div>
diff --git a/themes/bootstrap3/templates/search/controls/showing.phtml b/themes/bootstrap3/templates/search/controls/showing.phtml
index b2598bb229b6908bfaf09fef5bb6cbf00e574d98..704ef616163e9c4eae3f8305921ce545310745ef 100644
--- a/themes/bootstrap3/templates/search/controls/showing.phtml
+++ b/themes/bootstrap3/templates/search/controls/showing.phtml
@@ -11,8 +11,11 @@
     $transParams
   );
 ?>
+<?php // Use search-heading if it's set, but append only if it's not empty to avoid extra whitespace: ?>
 <?php if ($this->slot('search-heading')->isset()): ?>
-  <?php $showingResults .= ' ' . $this->slot('search-heading')->get(); ?>
+  <?php if ($heading = $this->slot('search-heading')->get()): ?>
+    <?php $showingResults .= " $heading"; ?>
+  <?php endif; ?>
 <?php elseif ($this->params->getSearchType() == 'basic'):
   $showingResults = $this->translate(
     isset($this->skipTotalCount) ? 'showing_results_for_html' : 'showing_results_of_for_html',
diff --git a/themes/bootstrap3/templates/search/results-scripts.phtml b/themes/bootstrap3/templates/search/results-scripts.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..1c61bccca75455ce75f8d1887e2798002becf7c1
--- /dev/null
+++ b/themes/bootstrap3/templates/search/results-scripts.phtml
@@ -0,0 +1,9 @@
+<?php
+// Load Javascript dependencies into header:
+$this->headScript()->appendFile("vendor/hunt.min.js");
+$this->headScript()->appendFile("check_item_statuses.js");
+$this->headScript()->appendFile("check_save_statuses.js");
+if ($this->displayVersions) {
+    $this->headScript()->appendFile("record_versions.js");
+    $this->headScript()->appendFile("combined-search.js");
+}
diff --git a/themes/bootstrap3/templates/search/results.phtml b/themes/bootstrap3/templates/search/results.phtml
index 938d198775f6ee9e9c9db6c7f1e023761ed8216e..55444d5650d01e48514c1544e72e1f9bf006890c 100644
--- a/themes/bootstrap3/templates/search/results.phtml
+++ b/themes/bootstrap3/templates/search/results.phtml
@@ -34,16 +34,13 @@
   // Enable bulk options if appropriate:
   $this->showCheckboxes = $this->showCartControls || $this->showBulkOptions;
 
-  // Load Javascript only if list view parameter is NOT full:
-  if ($this->params->getOptions()->getListViewOption() != "full") {
-    $this->headScript()->appendFile("record.js");
-    $this->headScript()->appendFile("embedded_record.js");
-  }
+  $this->render('search/results-scripts.phtml', ['displayVersions' => !empty($this->params->getOptions()->getVersionsAction())]);
 
-  // Load Javascript dependencies into header:
-  $this->headScript()->appendFile("vendor/hunt.min.js");
-  $this->headScript()->appendFile("check_item_statuses.js");
-  $this->headScript()->appendFile("check_save_statuses.js");
+  // Load only if list view parameter is NOT full:
+  if ($this->params->getOptions()->getListViewOption() !== 'full') {
+      $this->headScript()->appendFile("record.js");
+      $this->headScript()->appendFile("embedded_record.js");
+  }
 ?>
 
 <div class="<?=$this->layoutClass('mainbody')?>">
@@ -103,25 +100,7 @@
     <?=$this->render('search/list-' . $this->params->getView() . '.phtml')?>
     <?=$this->context($this)->renderInContext('search/bulk-action-buttons.phtml', ['idPrefix' => 'bottom_', 'formAttr' => 'search-cart-form'])?>
     <?=$this->paginationControl($this->results->getPaginator(), 'Sliding', 'search/pagination.phtml', ['results' => $this->results, 'options' => isset($this->paginationOptions) ? $this->paginationOptions : []])?>
-
-    <div class="searchtools hidden-print">
-      <strong><?=$this->transEsc('Search Tools')?>:</strong>
-      <a href="<?=$this->results->getUrlQuery()->setViewParam('rss')?>"><i class="fa fa-bell" aria-hidden="true"></i> <?=$this->transEsc('Get RSS Feed')?></a>
-      &mdash;
-      <a href="<?=$this->url('search-email')?>" class="mailSearch" data-lightbox id="mailSearch<?=$this->escapeHtmlAttr($this->results->getSearchId())?>">
-        <i class="fa fa-envelope" aria-hidden="true"></i> <?=$this->transEsc('Email this Search')?>
-      </a>
-      <?php if ($this->accountCapabilities()->getSavedSearchSetting() === 'enabled'): ?>
-        &mdash;
-        <?php if (is_numeric($this->results->getSearchId())): ?>
-          <?php if ($this->results->isSavedSearch()): ?>
-            <a href="<?=$this->url('myresearch-savesearch')?>?delete=<?=urlencode($this->results->getSearchId())?>"><i class="fa fa-remove" aria-hidden="true"></i> <?=$this->transEsc('save_search_remove')?></a>
-          <?php else: ?>
-            <a href="<?=$this->url('myresearch-savesearch')?>?save=<?=urlencode($this->results->getSearchId())?>"><i class="fa fa-save" aria-hidden="true"></i> <?=$this->transEsc('save_search')?></a>
-          <?php endif; ?>
-        <?php endif; ?>
-      <?php endif; ?>
-    </div>
+    <?=$this->context($this)->renderInContext('search/controls/results-tools.phtml', ['results' => $this->results])?>
   <?php endif; ?>
 </div>
 <?php /* End Main Listing */ ?>
diff --git a/themes/bootstrap3/templates/search/versions-searchbox.phtml b/themes/bootstrap3/templates/search/versions-searchbox.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..2a687d247faa857f2b495ddad1d7a6426e3248d8
--- /dev/null
+++ b/themes/bootstrap3/templates/search/versions-searchbox.phtml
@@ -0,0 +1,24 @@
+<?php
+    $options = $this->searchOptions($this->searchClassId);
+    $searchHome = $options->getSearchHomeAction();
+    $advSearch = $options->getAdvancedSearchAction();
+
+    $hiddenFilters = $this->searchTabs()->getHiddenFilters($this->searchClassId, false, false);
+    if (empty($hiddenFilters)) {
+        $hiddenFilters = $this->searchMemory()->getLastHiddenFilters($this->searchClassId);
+        if (empty($hiddenFilters)) {
+            $hiddenFilters = $this->searchTabs()->getHiddenFilters($this->searchClassId);
+        }
+    }
+    $hiddenFilterParams = $this->searchTabs()->getCurrentHiddenFilterParams($this->searchClassId, false, '?');
+?>
+<?php $tabConfig = $this->searchTabs()->getTabConfig($this->searchClassId, '', $this->searchIndex, 'basic', $hiddenFilters); ?>
+<?php $tabs = $this->context($this)->renderInContext('search/searchTabs', ['searchTabs' => $tabConfig['tabs']]); ?>
+<?php if (!empty($tabs)): ?><?=$tabs ?><div class="tab-content clearfix"><?php endif; ?>
+  <p class="adv_search_links">
+    <a href="<?=$this->url($searchHome) . $hiddenFilterParams?>"><?=$this->transEsc("Start a new Basic Search")?></a>
+    <?php if ($advSearch): ?>
+      | <a href="<?=$this->url($advSearch) . $hiddenFilterParams?>"><?=$this->transEsc("Start a new Advanced Search")?></a>
+    <?php endif; ?>
+  </p>
+<?php if (!empty($tabs)): ?></div><?php endif; ?>
diff --git a/themes/bootstrap3/templates/search/versions.phtml b/themes/bootstrap3/templates/search/versions.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..39762ece16be6f431cace5e05e7848309c29eb79
--- /dev/null
+++ b/themes/bootstrap3/templates/search/versions.phtml
@@ -0,0 +1,44 @@
+<?php
+  // Set up versions-specific details:
+  $title = $this->translate('Versions');
+  if ($this->driver) {
+    $title .= ' - ' . $this->driver->getBreadcrumb();
+  }
+  $this->slot('head-title')->set($title);
+  $this->slot('search-heading')->set('');
+  $this->slot('empty-message')->set(
+    $this->translate('nohit_lookfor_html', ['%%lookfor%%' => $this->transEsc('Versions')])
+  );
+
+  // Call the standard search template:
+  echo $this->render('search/results.phtml');
+
+  // Override searchbox:
+  $this->layout()->searchbox = $this->context($this)->renderInContext(
+      'search/versions-searchbox.phtml',
+      [
+        'searchType' => 'basic',
+        'searchIndex' => $this->params->getSearchHandler(),
+        'searchClassId' => $this->params->getSearchClassId(),
+        'selectedShards' => $this->params->getSelectedShards(),
+      ]
+  ) . $this->context($this)->renderInContext(
+    'search/filters.phtml',
+    [
+      'params' => $this->params,
+      'urlQuery' => $this->results->getUrlQuery(),
+      'filterList' => $this->params->getFilterList(true),
+      'checkboxFilters' => $this->params->getCheckboxFacets(),
+      'searchClassId' => $this->params->getSearchClassId(),
+      'searchType' => $this->params->getSearchType(),
+    ]
+  );
+
+  // Override breadcrumbs:
+  $titleLink = '';
+  if ($this->driver) {
+    $titleLink .= ' - <a href="' . $this->escapeHtmlAttr($this->recordLink()->getUrl($this->driver)) . '">' . $this->driver->getBreadcrumb() . '</a>';
+  }
+
+  $this->layout()->breadcrumbs = '<li class="active">' . $this->transEsc('Versions') . $titleLink . '</li>';
+?>
diff --git a/themes/bootstrap3/templates/search2/versions.phtml b/themes/bootstrap3/templates/search2/versions.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..c8c616a732f59e0837642464530973b435b6ab9f
--- /dev/null
+++ b/themes/bootstrap3/templates/search2/versions.phtml
@@ -0,0 +1 @@
+<?=$this->render('search/versions.phtml'); ?>