diff --git a/build-data/paper.at b/build-data/paper.at
index 8e1b527ed3..47ca2bb359 100644
--- a/build-data/paper.at
+++ b/build-data/paper.at
@@ -110,6 +110,8 @@ public net.minecraft.server.network.ServerLoginPacketListenerImpl connection
 public net.minecraft.server.network.ServerLoginPacketListenerImpl state
 public net.minecraft.server.network.ServerLoginPacketListenerImpl$State
 public net.minecraft.server.packs.VanillaPackResourcesBuilder safeGetPath(Ljava/net/URI;)Ljava/nio/file/Path;
+public net.minecraft.server.packs.repository.FolderRepositorySource$FolderPackDetector
+public net.minecraft.server.packs.repository.FolderRepositorySource$FolderPackDetector <init>(Lnet/minecraft/world/level/validation/DirectoryValidator;)V
 public net.minecraft.server.packs.repository.Pack resources
 public net.minecraft.server.players.PlayerList playerIo
 public net.minecraft.server.players.PlayerList players
diff --git a/paper-api/src/main/java/io/papermc/paper/datapack/Datapack.java b/paper-api/src/main/java/io/papermc/paper/datapack/Datapack.java
index 95039ec90f..7c99257866 100644
--- a/paper-api/src/main/java/io/papermc/paper/datapack/Datapack.java
+++ b/paper-api/src/main/java/io/papermc/paper/datapack/Datapack.java
@@ -1,10 +1,8 @@
 package io.papermc.paper.datapack;
 
-import java.util.Set;
 import net.kyori.adventure.text.Component;
-import org.bukkit.FeatureFlag;
+import org.jetbrains.annotations.ApiStatus;
 import org.jetbrains.annotations.Contract;
-import org.jetbrains.annotations.Unmodifiable;
 import org.jspecify.annotations.NullMarked;
 
 /**
@@ -12,50 +10,9 @@ import org.jspecify.annotations.NullMarked;
  * won't be updated as datapacks are updated.
  */
 @NullMarked
-public interface Datapack {
+@ApiStatus.NonExtendable
+public interface Datapack extends DiscoveredDatapack {
 
-    /**
-     * Gets the name/id of this datapack.
-     *
-     * @return the name of the pack
-     */
-    @Contract(pure = true)
-    String getName();
-
-    /**
-     * Gets the title component of this datapack.
-     *
-     * @return the title
-     */
-    Component getTitle();
-
-    /**
-     * Gets the description component of this datapack.
-     *
-     * @return the description
-     */
-    Component getDescription();
-
-    /**
-     * Gets if this datapack is required to be enabled.
-     *
-     * @return true if the pack is required
-     */
-    boolean isRequired();
-
-    /**
-     * Gets the compatibility status of this pack.
-     *
-     * @return the compatibility of the pack
-     */
-    Compatibility getCompatibility();
-
-    /**
-     * Gets the set of required features for this datapack.
-     *
-     * @return the set of required features
-     */
-    @Unmodifiable Set<FeatureFlag> getRequiredFeatures();
 
     /**
      * Gets the enabled state of this pack.
@@ -74,13 +31,6 @@ public interface Datapack {
      */
     void setEnabled(boolean enabled);
 
-    /**
-     * Gets the source for this datapack.
-     *
-     * @return the pack source
-     */
-    DatapackSource getSource();
-
     /**
      * Computes the component vanilla Minecraft uses
      * to display this datapack. Includes the {@link #getSource()},
@@ -96,4 +46,11 @@ public interface Datapack {
         TOO_NEW,
         COMPATIBLE,
     }
+
+    /**
+     * Position of the pack in the load order.
+     */
+    enum Position {
+        TOP, BOTTOM
+    }
 }
diff --git a/paper-api/src/main/java/io/papermc/paper/datapack/DatapackRegistrar.java b/paper-api/src/main/java/io/papermc/paper/datapack/DatapackRegistrar.java
new file mode 100644
index 0000000000..456c26c929
--- /dev/null
+++ b/paper-api/src/main/java/io/papermc/paper/datapack/DatapackRegistrar.java
@@ -0,0 +1,204 @@
+package io.papermc.paper.datapack;
+
+import io.papermc.paper.plugin.configuration.PluginMeta;
+import io.papermc.paper.plugin.lifecycle.event.registrar.Registrar;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.function.Consumer;
+import net.kyori.adventure.text.Component;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Unmodifiable;
+
+/**
+ * The registrar for datapacks. The event for this registrar
+ * is called anytime the game tries to discover datapacks at any of the
+ * configured locations. This means that if a datapack should stay available to the server,
+ * it must always be discovered whenever this event fires.
+ * <p>An example of a plugin loading a datapack from within it's own jar is below</p>
+ * <pre>{@code
+ * public class YourPluginBootstrap implements PluginBootstrap {
+ *     @Override
+ *     public void bootstrap(BoostrapContext context) {
+ *         final LifecycleEventManager<BootstrapContext> manager = context.getLifecycleManager();
+ *         manager.registerEventHandler(LifecycleEvents.DATAPACK_DISCOVERY, event -> {
+ *             DatapackRegistrar registrar = event.registrar();
+ *             try {
+ *                 final URI uri = Objects.requireNonNull(
+ *                     YourPluginBootstrap.class.getResource("/pack")
+ *                 ).toURI();
+ *                 registrar.discoverPack(uri, "packId");
+ *             } catch (final URISyntaxException | IOException e) {
+ *                 throw new RuntimeException(e);
+ *             }
+ *         });
+ *     }
+ * }
+ * }</pre>
+ * @see io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents#DATAPACK_DISCOVERY
+ */
+@ApiStatus.NonExtendable
+@ApiStatus.Experimental
+public interface DatapackRegistrar extends Registrar {
+
+    /**
+     * Checks if a datapack with the specified name has been discovered.
+     *
+     * @param name the name of the pack
+     * @return true if the pack has been discovered
+     * @see Datapack#getName()
+     */
+    @Contract(pure = true)
+    boolean hasPackDiscovered(@NonNull String name);
+
+    /**
+     * Gets a discovered datapack by its name.
+     *
+     * @param name the name of the pack
+     * @return the datapack
+     * @throws java.util.NoSuchElementException if the pack is not discovered
+     * @see Datapack#getName()
+     */
+    @Contract(pure = true)
+    @NonNull DiscoveredDatapack getDiscoveredPack(@NonNull String name);
+
+    /**
+     * Removes a discovered datapack by its name.
+     *
+     * @param name the name of the pack
+     * @return true if the pack was removed
+     * @see Datapack#getName()
+     */
+    @Contract(mutates = "this")
+    boolean removeDiscoveredPack(@NonNull String name);
+
+    /**
+     * Gets all discovered datapacks.
+     *
+     * @return an unmodifiable map of discovered packs
+     */
+    @Contract(pure = true)
+    @Unmodifiable @NonNull Map<String, DiscoveredDatapack> getDiscoveredPacks();
+
+    /**
+     * Discovers a datapack at the specified {@link URI} with the id.
+     * <p>Symlinks obey the {@code allowed_symlinks.txt} in the server root directory.</p>
+     *
+     * @param uri the location of the pack
+     * @param id a unique id (will be combined with plugin for the datapacks name)
+     * @return the discovered datapack (or null if it failed)
+     * @throws IOException if any IO error occurs
+     */
+    default @Nullable DiscoveredDatapack discoverPack(final @NonNull URI uri, final @NonNull String id) throws IOException {
+        return this.discoverPack(uri, id, c -> {});
+    }
+
+    /**
+     * Discovers a datapack at the specified {@link URI} with the id.
+     * <p>Symlinks obey the {@code allowed_symlinks.txt} in the server root directory.</p>
+     *
+     * @param uri the location of the pack
+     * @param id a unique id (will be combined with plugin for the datapacks name)
+     * @param configurer a configurer for extra options
+     * @return the discovered datapack (or null if it failed)
+     * @throws IOException if any IO error occurs
+     */
+    @Nullable DiscoveredDatapack discoverPack(@NonNull URI uri, @NonNull String id, @NonNull Consumer<Configurer> configurer) throws IOException;
+
+    /**
+     * Discovers a datapack at the specified {@link Path} with the id.
+     * <p>Symlinks obey the {@code allowed_symlinks.txt} in the server root directory.</p>
+     *
+     * @param path the location of the pack
+     * @param id a unique id (will be combined with plugin for the datapacks name)
+     * @return the discovered datapack (or null if it failed)
+     * @throws IOException if any IO error occurs
+     */
+    default @Nullable DiscoveredDatapack discoverPack(final @NonNull Path path, final @NonNull String id) throws IOException {
+        return this.discoverPack(path, id, c -> {});
+    }
+
+    /**
+     * Discovers a datapack at the specified {@link Path} with the id.
+     * <p>Symlinks obey the {@code allowed_symlinks.txt} in the server root directory.</p>
+     *
+     * @param path the location of the pack
+     * @param id a unique id (will be combined with plugin for the datapacks name)
+     * @param configurer a configurer for extra options
+     * @return the discovered datapack (or null if it failed)
+     * @throws IOException if any IO error occurs
+     */
+    @Nullable DiscoveredDatapack discoverPack(@NonNull Path path, @NonNull String id, @NonNull Consumer<Configurer> configurer) throws IOException;
+
+    /**
+     * Discovers a datapack at the specified {@link URI} with the id.
+     * <p>Symlinks obey the {@code allowed_symlinks.txt} in the server root directory.</p>
+     *
+     * @param pluginMeta the plugin which will be the "owner" of this datapack
+     * @param uri the location of the pack
+     * @param id a unique id (will be combined with plugin for the datapacks name)
+     * @param configurer a configurer for extra options
+     * @return the discovered datapack (or null if it failed)
+     * @throws IOException if any IO error occurs
+     */
+    @Nullable DiscoveredDatapack discoverPack(@NonNull PluginMeta pluginMeta, @NonNull URI uri, @NonNull String id, @NonNull Consumer<Configurer> configurer) throws IOException;
+
+    /**
+     * Discovers a datapack at the specified {@link Path} with the id.
+     * <p>Symlinks obey the {@code allowed_symlinks.txt} in the server root directory.</p>
+     *
+     * @param pluginMeta the plugin which will be the "owner" of this datapack
+     * @param path the location of the pack
+     * @param id a unique id (will be combined with plugin for the datapacks name)
+     * @param configurer a configurer for extra options
+     * @return the discovered datapack (or null if it failed)
+     * @throws IOException if any IO error occurs
+     */
+    @Nullable DiscoveredDatapack discoverPack(@NonNull PluginMeta pluginMeta, @NonNull Path path, @NonNull String id, @NonNull Consumer<Configurer> configurer) throws IOException;
+
+    /**
+     * Configures additional, optional, details about a datapack.
+     */
+    @ApiStatus.NonExtendable
+    @ApiStatus.Experimental
+    interface Configurer {
+
+        /**
+         * Changes the title of the datapack from the default which
+         * is just the "id" in the {@code registerPack} methods.
+         *
+         * @param title the new title
+         * @return the configurer for chaining
+         */
+        @Contract(value = "_ -> this", mutates = "this")
+        @NonNull Configurer title(@NonNull Component title);
+
+        /**
+         * Sets if this pack is required. Defaults to false.
+         * A required pack cannot be disabled once enabled. Marking
+         * a pack as required <b>does not</b> mean it will immediately be enabled
+         * upon discovery. It may be enabled if this event was fired
+         * due to a pending (re)load.
+         *
+         * @param required true to require the pack
+         * @return the configurer for chaining
+         */
+        @Contract(value = "_ -> this", mutates = "this")
+        @NonNull Configurer required(boolean required);
+
+        /**
+         * Configures the position in the
+         * load order of this datapack.
+         *
+         * @param fixed won't move around in the load order as packs are added/removed
+         * @param position try to insert at the top of the order or bottom
+         * @return the configurer for chaining
+         */
+        @Contract(value = "_, _ -> this", mutates = "this")
+        @NonNull Configurer position(boolean fixed, Datapack.@NonNull Position position);
+    }
+}
diff --git a/paper-api/src/main/java/io/papermc/paper/datapack/DiscoveredDatapack.java b/paper-api/src/main/java/io/papermc/paper/datapack/DiscoveredDatapack.java
new file mode 100644
index 0000000000..109b930095
--- /dev/null
+++ b/paper-api/src/main/java/io/papermc/paper/datapack/DiscoveredDatapack.java
@@ -0,0 +1,69 @@
+package io.papermc.paper.datapack;
+
+import java.util.Set;
+import net.kyori.adventure.text.Component;
+import org.bukkit.FeatureFlag;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Unmodifiable;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * This is a snapshot of a discovered datapack on the server. It
+ * won't be updated as datapacks are updated.
+ */
+@NullMarked
+@ApiStatus.NonExtendable
+public interface DiscoveredDatapack {
+
+    /**
+     * Gets the name/id of this datapack.
+     *
+     * @return the name of the pack
+     */
+    @Contract(pure = true)
+    String getName();
+
+    /**
+     * Gets the title component of this datapack.
+     *
+     * @return the title
+     */
+    Component getTitle();
+
+    /**
+     * Gets the description component of this datapack.
+     *
+     * @return the description
+     */
+    Component getDescription();
+
+    /**
+     * Gets if this datapack is required to be enabled.
+     *
+     * @return true if the pack is required
+     */
+    boolean isRequired();
+
+    /**
+     * Gets the compatibility status of this pack.
+     *
+     * @return the compatibility of the pack
+     */
+    Datapack.Compatibility getCompatibility();
+
+    /**
+     * Gets the set of required features for this datapack.
+     *
+     * @return the set of required features
+     */
+    @Unmodifiable
+    Set<FeatureFlag> getRequiredFeatures();
+
+    /**
+     * Gets the source for this datapack.
+     *
+     * @return the pack source
+     */
+    DatapackSource getSource();
+}
diff --git a/paper-api/src/main/java/io/papermc/paper/plugin/lifecycle/event/types/LifecycleEvents.java b/paper-api/src/main/java/io/papermc/paper/plugin/lifecycle/event/types/LifecycleEvents.java
index b65c5fd544..8ff52773b0 100644
--- a/paper-api/src/main/java/io/papermc/paper/plugin/lifecycle/event/types/LifecycleEvents.java
+++ b/paper-api/src/main/java/io/papermc/paper/plugin/lifecycle/event/types/LifecycleEvents.java
@@ -1,10 +1,12 @@
 package io.papermc.paper.plugin.lifecycle.event.types;
 
 import io.papermc.paper.command.brigadier.Commands;
+import io.papermc.paper.datapack.DatapackRegistrar;
 import io.papermc.paper.plugin.bootstrap.BootstrapContext;
 import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
 import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager;
 import io.papermc.paper.plugin.lifecycle.event.LifecycleEventOwner;
+import io.papermc.paper.plugin.lifecycle.event.registrar.RegistrarEvent;
 import io.papermc.paper.plugin.lifecycle.event.registrar.ReloadableRegistrarEvent;
 import org.bukkit.plugin.Plugin;
 import org.jetbrains.annotations.ApiStatus;
@@ -32,6 +34,14 @@ public final class LifecycleEvents {
      */
     public static final TagEventTypeProvider TAGS = LifecycleEventTypeProvider.provider().tagProvider();
 
+
+    /**
+     * This event is for informing the server about any available datapacks from other sources such as inside a plugin's jar. You
+     * can register a handler for this event only in {@link io.papermc.paper.plugin.bootstrap.PluginBootstrap#bootstrap(BootstrapContext)}.
+     * @see DatapackRegistrar an example of a datapack being discovered
+     */
+    public static final LifecycleEventType.Prioritizable<BootstrapContext, RegistrarEvent<DatapackRegistrar>> DATAPACK_DISCOVERY = bootstrapPrioritized("datapack_discovery");
+
     //<editor-fold desc="helper methods" defaultstate="collapsed">
     @ApiStatus.Internal
     static <E extends LifecycleEvent> LifecycleEventType.Monitorable<Plugin, E> plugin(final String name) {
diff --git a/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch b/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch
index d8518cce12..019f5d45a1 100644
--- a/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch
+++ b/paper-server/patches/sources/net/minecraft/server/MinecraftServer.java.patch
@@ -1232,6 +1232,15 @@
                  },
                  this
              );
+@@ -1550,7 +_,7 @@
+         DataPackConfig dataPackConfig = initialDataConfig.dataPacks();
+         FeatureFlagSet featureFlagSet = initMode ? FeatureFlagSet.of() : initialDataConfig.enabledFeatures();
+         FeatureFlagSet featureFlagSet1 = initMode ? FeatureFlags.REGISTRY.allFlags() : initialDataConfig.enabledFeatures();
+-        packRepository.reload();
++        packRepository.reload(true); // Paper - will load resource packs
+         if (safeMode) {
+             return configureRepositoryWithSelection(packRepository, List.of("vanilla"), featureFlagSet, false);
+         } else {
 @@ -1652,10 +_,11 @@
          if (this.isEnforceWhitelist()) {
              PlayerList playerList = commandSource.getServer().getPlayerList();
diff --git a/paper-server/patches/sources/net/minecraft/server/commands/ReloadCommand.java.patch b/paper-server/patches/sources/net/minecraft/server/commands/ReloadCommand.java.patch
index 47fc313fa8..cfcc1b2b54 100644
--- a/paper-server/patches/sources/net/minecraft/server/commands/ReloadCommand.java.patch
+++ b/paper-server/patches/sources/net/minecraft/server/commands/ReloadCommand.java.patch
@@ -9,6 +9,15 @@
              LOGGER.warn("Failed to execute reload", throwable);
              source.sendFailure(Component.translatable("commands.reload.failure"));
              return null;
+@@ -24,7 +_,7 @@
+     }
+ 
+     private static Collection<String> discoverNewPacks(PackRepository packRepository, WorldData worldData, Collection<String> selectedIds) {
+-        packRepository.reload();
++        packRepository.reload(true); // Paper - will perform a full reload
+         Collection<String> list = Lists.newArrayList(selectedIds);
+         Collection<String> disabled = worldData.getDataConfiguration().dataPacks().getDisabled();
+ 
 @@ -36,6 +_,16 @@
  
          return list;
diff --git a/paper-server/patches/sources/net/minecraft/server/packs/repository/PackRepository.java.patch b/paper-server/patches/sources/net/minecraft/server/packs/repository/PackRepository.java.patch
new file mode 100644
index 0000000000..d1064bb1e6
--- /dev/null
+++ b/paper-server/patches/sources/net/minecraft/server/packs/repository/PackRepository.java.patch
@@ -0,0 +1,75 @@
+--- a/net/minecraft/server/packs/repository/PackRepository.java
++++ b/net/minecraft/server/packs/repository/PackRepository.java
+@@ -21,9 +_,13 @@
+     private final Set<RepositorySource> sources;
+     private Map<String, Pack> available = ImmutableMap.of();
+     private List<Pack> selected = ImmutableList.of();
++    private final net.minecraft.world.level.validation.DirectoryValidator validator; // Paper - add validator
+ 
+-    public PackRepository(RepositorySource... sources) {
+-        this.sources = ImmutableSet.copyOf(sources);
++    // Paper start - add validator
++    public PackRepository(net.minecraft.world.level.validation.DirectoryValidator validator, RepositorySource... providers) {
++        this.validator = validator;
++        // Paper end - add validator
++        this.sources = ImmutableSet.copyOf(providers);
+     }
+ 
+     public static String displayPackList(Collection<Pack> packs) {
+@@ -31,9 +_,14 @@
+     }
+ 
+     public void reload() {
++        // Paper start - perform a full reload
++        this.reload(false);
++    }
++    public void reload(boolean addRequiredPacks) {
++        // Paper end
+         List<String> list = this.selected.stream().map(Pack::getId).collect(ImmutableList.toImmutableList());
+         this.available = this.discoverAvailable();
+-        this.selected = this.rebuildSelected(list);
++        this.selected = this.rebuildSelected(list, addRequiredPacks); // Paper
+     }
+ 
+     private Map<String, Pack> discoverAvailable() {
+@@ -43,16 +_,23 @@
+             repositorySource.loadPacks(pack -> map.put(pack.getId(), pack));
+         }
+ 
+-        return ImmutableMap.copyOf(map);
++        // Paper start - custom plugin-loaded datapacks
++        final io.papermc.paper.datapack.PaperDatapackRegistrar registrar = new io.papermc.paper.datapack.PaperDatapackRegistrar(this.validator, map);
++        io.papermc.paper.plugin.lifecycle.event.LifecycleEventRunner.INSTANCE.callStaticRegistrarEvent(io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents.DATAPACK_DISCOVERY,
++            registrar,
++            io.papermc.paper.plugin.bootstrap.BootstrapContext.class
++        );
++        return ImmutableMap.copyOf(registrar.discoveredPacks);
++        // Paper end - custom plugin-loaded datapacks
+     }
+ 
+     public boolean isAbleToClearAnyPack() {
+-        List<Pack> list = this.rebuildSelected(List.of());
++        List<Pack> list = this.rebuildSelected(List.of(), false); // Paper - add willReload boolean
+         return !this.selected.equals(list);
+     }
+ 
+     public void setSelected(Collection<String> ids) {
+-        this.selected = this.rebuildSelected(ids);
++        this.selected = this.rebuildSelected(ids, false); // Paper - add willReload boolean
+     }
+ 
+     public boolean addPack(String id) {
+@@ -79,11 +_,11 @@
+         }
+     }
+ 
+-    private List<Pack> rebuildSelected(Collection<String> ids) {
++    private List<Pack> rebuildSelected(Collection<String> ids, boolean addRequiredPacks) { // Paper - add addRequiredPacks boolean
+         List<Pack> list = this.getAvailablePacks(ids).collect(Util.toMutableList());
+ 
+         for (Pack pack : this.available.values()) {
+-            if (pack.isRequired() && !list.contains(pack)) {
++            if (pack.isRequired() && !list.contains(pack) && addRequiredPacks) { // Paper - add addRequiredPacks boolean
+                 pack.getDefaultPosition().insert(list, pack, Pack::selectionConfig, false);
+             }
+         }
diff --git a/paper-server/patches/sources/net/minecraft/server/packs/repository/ServerPacksSource.java.patch b/paper-server/patches/sources/net/minecraft/server/packs/repository/ServerPacksSource.java.patch
index dc3a6578a8..ee3142e9b5 100644
--- a/paper-server/patches/sources/net/minecraft/server/packs/repository/ServerPacksSource.java.patch
+++ b/paper-server/patches/sources/net/minecraft/server/packs/repository/ServerPacksSource.java.patch
@@ -9,7 +9,7 @@
              .applyDevelopmentConfig()
              .pushJarResources()
              .build(VANILLA_PACK_INFO);
-@@ -68,7 +_,18 @@
+@@ -68,15 +_,26 @@
      @Nullable
      @Override
      protected Pack createBuiltinPack(String id, Pack.ResourcesSupplier resources, Component title) {
@@ -29,3 +29,13 @@
      }
  
      public static PackRepository createPackRepository(Path folder, DirectoryValidator validator) {
+-        return new PackRepository(new ServerPacksSource(validator), new FolderRepositorySource(folder, PackType.SERVER_DATA, PackSource.WORLD, validator));
++        return new PackRepository(validator, new ServerPacksSource(validator), new FolderRepositorySource(folder, PackType.SERVER_DATA, PackSource.WORLD, validator)); // Paper - add validator
+     }
+ 
+     public static PackRepository createVanillaTrustedRepository() {
+-        return new PackRepository(new ServerPacksSource(new DirectoryValidator(path -> true)));
++        return new PackRepository(new DirectoryValidator(path -> true), new ServerPacksSource(new DirectoryValidator(path -> true))); // Paper - add validator
+     }
+ 
+     public static PackRepository createPackRepository(LevelStorageSource.LevelStorageAccess level) {
diff --git a/paper-server/src/main/java/io/papermc/paper/datapack/PaperDatapack.java b/paper-server/src/main/java/io/papermc/paper/datapack/PaperDatapack.java
index 8bd8263b51..bbd709529c 100644
--- a/paper-server/src/main/java/io/papermc/paper/datapack/PaperDatapack.java
+++ b/paper-server/src/main/java/io/papermc/paper/datapack/PaperDatapack.java
@@ -2,71 +2,25 @@ package io.papermc.paper.datapack;
 
 import io.papermc.paper.adventure.PaperAdventure;
 import io.papermc.paper.event.server.ServerResourcesReloadedEvent;
-import io.papermc.paper.world.flag.PaperFeatureFlagProviderImpl;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
 import net.kyori.adventure.text.Component;
 import net.minecraft.server.MinecraftServer;
 import net.minecraft.server.packs.repository.Pack;
-import net.minecraft.server.packs.repository.PackSource;
-import org.bukkit.FeatureFlag;
-import org.checkerframework.checker.nullness.qual.NonNull;
-import org.checkerframework.checker.nullness.qual.Nullable;
-import org.checkerframework.framework.qual.DefaultQualifier;
+import org.jspecify.annotations.NullMarked;
 
-@DefaultQualifier(NonNull.class)
-public class PaperDatapack implements Datapack {
-
-    private static final Map<PackSource, DatapackSource> PACK_SOURCES = new ConcurrentHashMap<>();
-    static {
-        PACK_SOURCES.put(PackSource.DEFAULT, DatapackSource.DEFAULT);
-        PACK_SOURCES.put(PackSource.BUILT_IN, DatapackSource.BUILT_IN);
-        PACK_SOURCES.put(PackSource.FEATURE, DatapackSource.FEATURE);
-        PACK_SOURCES.put(PackSource.WORLD, DatapackSource.WORLD);
-        PACK_SOURCES.put(PackSource.SERVER, DatapackSource.SERVER);
-    }
+@NullMarked
+public class PaperDatapack extends PaperDiscoveredDatapack implements Datapack {
 
     private final Pack pack;
     private final boolean enabled;
 
     PaperDatapack(final Pack pack, final boolean enabled) {
+        super(pack);
         this.pack = pack;
         this.enabled = enabled;
     }
 
-    @Override
-    public String getName() {
-        return this.pack.getId();
-    }
-
-    @Override
-    public Component getTitle() {
-        return PaperAdventure.asAdventure(this.pack.getTitle());
-    }
-
-    @Override
-    public Component getDescription() {
-        return PaperAdventure.asAdventure(this.pack.getDescription());
-    }
-
-    @Override
-    public boolean isRequired() {
-        return this.pack.isRequired();
-    }
-
-    @Override
-    public Compatibility getCompatibility() {
-        return Datapack.Compatibility.valueOf(this.pack.getCompatibility().name());
-    }
-
-    @Override
-    public Set<FeatureFlag> getRequiredFeatures() {
-        return PaperFeatureFlagProviderImpl.fromNms(this.pack.getRequestedFeatures());
-    }
-
     @Override
     public boolean isEnabled() {
         return this.enabled;
@@ -76,7 +30,7 @@ public class PaperDatapack implements Datapack {
     public void setEnabled(final boolean enabled) {
         final MinecraftServer server = MinecraftServer.getServer();
         final List<Pack> enabledPacks = new ArrayList<>(server.getPackRepository().getSelectedPacks());
-        final @Nullable Pack packToChange = server.getPackRepository().getPack(this.getName());
+        final Pack packToChange = server.getPackRepository().getPack(this.getName());
         if (packToChange == null) {
             throw new IllegalStateException("Cannot toggle state of pack that doesn't exist: " + this.getName());
         }
@@ -91,11 +45,6 @@ public class PaperDatapack implements Datapack {
         server.reloadResources(enabledPacks.stream().map(Pack::getId).toList(), ServerResourcesReloadedEvent.Cause.PLUGIN);
     }
 
-    @Override
-    public DatapackSource getSource() {
-        return PACK_SOURCES.computeIfAbsent(this.pack.location().source(), source -> new DatapackSourceImpl(source.toString()));
-    }
-
     @Override
     public Component computeDisplayName() {
         return PaperAdventure.asAdventure(this.pack.getChatLink(this.enabled));
diff --git a/paper-server/src/main/java/io/papermc/paper/datapack/PaperDatapackRegistrar.java b/paper-server/src/main/java/io/papermc/paper/datapack/PaperDatapackRegistrar.java
new file mode 100644
index 0000000000..c8a854bd90
--- /dev/null
+++ b/paper-server/src/main/java/io/papermc/paper/datapack/PaperDatapackRegistrar.java
@@ -0,0 +1,165 @@
+package io.papermc.paper.datapack;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.mojang.logging.LogUtils;
+import io.papermc.paper.adventure.PaperAdventure;
+import io.papermc.paper.plugin.bootstrap.BootstrapContext;
+import io.papermc.paper.plugin.configuration.PluginMeta;
+import io.papermc.paper.plugin.lifecycle.event.registrar.PaperRegistrar;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.function.Consumer;
+import net.kyori.adventure.text.Component;
+import net.minecraft.server.packs.PackLocationInfo;
+import net.minecraft.server.packs.PackSelectionConfig;
+import net.minecraft.server.packs.PackType;
+import net.minecraft.server.packs.VanillaPackResourcesBuilder;
+import net.minecraft.server.packs.repository.FolderRepositorySource;
+import net.minecraft.server.packs.repository.Pack;
+import net.minecraft.server.packs.repository.PackDetector;
+import net.minecraft.world.level.validation.ContentValidationException;
+import net.minecraft.world.level.validation.DirectoryValidator;
+import net.minecraft.world.level.validation.ForbiddenSymlinkInfo;
+import org.jetbrains.annotations.Unmodifiable;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+
+@NullMarked
+public class PaperDatapackRegistrar implements PaperRegistrar<BootstrapContext>, DatapackRegistrar {
+
+    private static final Logger LOGGER = LogUtils.getClassLogger();
+
+    private final PackDetector<Pack.ResourcesSupplier> detector;
+    public final Map<String, Pack> discoveredPacks;
+    private @Nullable BootstrapContext owner;
+
+    public PaperDatapackRegistrar(final DirectoryValidator symlinkValidator, final Map<String, Pack> discoveredPacks) {
+        this.detector = new FolderRepositorySource.FolderPackDetector(symlinkValidator);
+        this.discoveredPacks = discoveredPacks;
+    }
+
+    @Override
+    public void setCurrentContext(final @Nullable BootstrapContext owner) {
+        this.owner = owner;
+    }
+
+    @Override
+    public boolean hasPackDiscovered(final String name) {
+        return this.discoveredPacks.containsKey(name);
+    }
+
+    @Override
+    public DiscoveredDatapack getDiscoveredPack(final String name) {
+        if (!this.hasPackDiscovered(name)) {
+            throw new NoSuchElementException("No pack with id " + name + " was discovered");
+        }
+        return new PaperDiscoveredDatapack(this.discoveredPacks.get(name));
+    }
+
+    @Override
+    public boolean removeDiscoveredPack(final String name) {
+        return this.discoveredPacks.remove(name) != null;
+    }
+
+    @Override
+    public @Unmodifiable Map<String, DiscoveredDatapack> getDiscoveredPacks() {
+        final ImmutableMap.Builder<String, DiscoveredDatapack> builder = ImmutableMap.builderWithExpectedSize(this.discoveredPacks.size());
+        for (final Map.Entry<String, Pack> entry : this.discoveredPacks.entrySet()) {
+            builder.put(entry.getKey(), new PaperDiscoveredDatapack(entry.getValue()));
+        }
+        return builder.build();
+    }
+
+    @Override
+    public @Nullable DiscoveredDatapack discoverPack(final URI uri, final String id, final Consumer<Configurer> configurer) throws IOException {
+        Preconditions.checkState(this.owner != null, "Cannot register a datapack without specifying a PluginMeta yet");
+        return this.discoverPack(this.owner.getPluginMeta(), uri, id, configurer);
+    }
+
+    @Override
+    public @Nullable DiscoveredDatapack discoverPack(final Path path, final String id, final Consumer<Configurer> configurer) throws IOException {
+        Preconditions.checkState(this.owner != null, "Cannot register a datapack without specifying a PluginMeta yet");
+        return this.discoverPack(this.owner.getPluginMeta(), path, id, configurer);
+    }
+
+    @Override
+    public @Nullable DiscoveredDatapack discoverPack(final PluginMeta pluginMeta, final URI uri, final String id, final Consumer<Configurer> configurer) throws IOException {
+        return this.discoverPack(pluginMeta, VanillaPackResourcesBuilder.safeGetPath(uri), id, configurer);
+    }
+
+    @Override
+    public @Nullable DiscoveredDatapack discoverPack(final PluginMeta pluginMeta, final Path path, final String id, final Consumer<Configurer> configurer) throws IOException {
+        final List<ForbiddenSymlinkInfo> badLinks = new ArrayList<>();
+        final Pack.ResourcesSupplier resourcesSupplier = this.detector.detectPackResources(path, badLinks);
+        if (!badLinks.isEmpty()) {
+            LOGGER.warn("Ignoring potential pack entry: {}", ContentValidationException.getMessage(path, badLinks));
+        } else if (resourcesSupplier != null) {
+            final String packId = pluginMeta.getName() + "/" + id;
+            final ConfigurerImpl configurerImpl = new ConfigurerImpl(Component.text(packId));
+            configurer.accept(configurerImpl);
+            final PackLocationInfo locInfo = new PackLocationInfo(packId,
+                PaperAdventure.asVanilla(configurerImpl.title),
+                PluginPackSource.INSTANCE,
+                Optional.empty()
+            );
+            final Pack pack = Pack.readMetaAndCreate(locInfo,
+                resourcesSupplier,
+                PackType.SERVER_DATA,
+                new PackSelectionConfig(
+                    configurerImpl.required,
+                    configurerImpl.position,
+                    configurerImpl.fixedPosition
+                ));
+            if (pack != null) {
+                this.discoveredPacks.put(packId, pack);
+                return new PaperDiscoveredDatapack(pack);
+            }
+            return null;
+        } else {
+            LOGGER.info("Found non-pack entry '{}', ignoring", path);
+        }
+        return null;
+    }
+
+    static final class ConfigurerImpl implements Configurer {
+
+        private Component title;
+        private boolean required = false;
+        private boolean fixedPosition = false;
+        private Pack.Position position = Pack.Position.TOP;
+
+        ConfigurerImpl(final Component title) {
+            this.title = title;
+        }
+
+        @Override
+        public Configurer title(final Component title) {
+            this.title = title;
+            return this;
+        }
+
+        @Override
+        public Configurer required(final boolean required) {
+            this.required = required;
+            return this;
+        }
+
+        @Override
+        public Configurer position(final boolean fixed, final Datapack.Position position) {
+            this.fixedPosition = fixed;
+            this.position = switch (position) {
+                case TOP -> Pack.Position.TOP;
+                case BOTTOM -> Pack.Position.BOTTOM;
+            };
+            return this;
+        }
+    }
+}
diff --git a/paper-server/src/main/java/io/papermc/paper/datapack/PaperDiscoveredDatapack.java b/paper-server/src/main/java/io/papermc/paper/datapack/PaperDiscoveredDatapack.java
new file mode 100644
index 0000000000..7267a016bd
--- /dev/null
+++ b/paper-server/src/main/java/io/papermc/paper/datapack/PaperDiscoveredDatapack.java
@@ -0,0 +1,68 @@
+package io.papermc.paper.datapack;
+
+import com.google.common.collect.ImmutableMap;
+import io.papermc.paper.adventure.PaperAdventure;
+import io.papermc.paper.world.flag.PaperFeatureFlagProviderImpl;
+import java.util.Map;
+import java.util.Set;
+import net.kyori.adventure.text.Component;
+import net.minecraft.server.packs.repository.Pack;
+import net.minecraft.server.packs.repository.PackSource;
+import org.bukkit.FeatureFlag;
+import org.jspecify.annotations.NullMarked;
+
+@NullMarked
+public class PaperDiscoveredDatapack implements DiscoveredDatapack {
+
+    private static final Map<PackSource, DatapackSource> PACK_SOURCES;
+    static {
+        PACK_SOURCES = ImmutableMap.<PackSource, DatapackSource>builder()
+            .put(PackSource.DEFAULT, DatapackSource.DEFAULT)
+            .put(PackSource.BUILT_IN, DatapackSource.BUILT_IN)
+            .put(PackSource.FEATURE, DatapackSource.FEATURE)
+            .put(PackSource.WORLD, DatapackSource.WORLD)
+            .put(PackSource.SERVER, DatapackSource.SERVER)
+            .buildOrThrow();
+    }
+
+    private final Pack pack;
+
+    PaperDiscoveredDatapack(final Pack pack) {
+        this.pack = pack;
+    }
+
+    @Override
+    public String getName() {
+        return this.pack.getId();
+    }
+
+    @Override
+    public Component getTitle() {
+        return PaperAdventure.asAdventure(this.pack.getTitle());
+    }
+
+    @Override
+    public Component getDescription() {
+        return PaperAdventure.asAdventure(this.pack.getDescription());
+    }
+
+    @Override
+    public boolean isRequired() {
+        return this.pack.isRequired();
+    }
+
+    @Override
+    public Datapack.Compatibility getCompatibility() {
+        return Datapack.Compatibility.valueOf(this.pack.getCompatibility().name());
+    }
+
+    @Override
+    public Set<FeatureFlag> getRequiredFeatures() {
+        return PaperFeatureFlagProviderImpl.fromNms(this.pack.getRequestedFeatures());
+    }
+
+    @Override
+    public DatapackSource getSource() {
+        return PACK_SOURCES.computeIfAbsent(this.pack.location().source(), source -> new DatapackSourceImpl(source.toString()));
+    }
+}
diff --git a/paper-server/src/main/java/io/papermc/paper/datapack/PluginPackSource.java b/paper-server/src/main/java/io/papermc/paper/datapack/PluginPackSource.java
new file mode 100644
index 0000000000..79a92a2a40
--- /dev/null
+++ b/paper-server/src/main/java/io/papermc/paper/datapack/PluginPackSource.java
@@ -0,0 +1,25 @@
+package io.papermc.paper.datapack;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+import net.minecraft.server.packs.repository.PackSource;
+import org.jspecify.annotations.NullMarked;
+
+@NullMarked
+final class PluginPackSource implements PackSource {
+
+    static final PackSource INSTANCE = new PluginPackSource();
+
+    private PluginPackSource() {
+    }
+
+    @Override
+    public Component decorate(final Component packDisplayName) {
+        return Component.translatable("pack.nameAndSource", packDisplayName, "plugin").withStyle(ChatFormatting.GRAY);
+    }
+
+    @Override
+    public boolean shouldAddAutomatically() {
+        return true;
+    }
+}
diff --git a/test-plugin/src/main/java/io/papermc/testplugin/TestPluginBootstrap.java b/test-plugin/src/main/java/io/papermc/testplugin/TestPluginBootstrap.java
index fe2b287b25..bfb7009bcb 100644
--- a/test-plugin/src/main/java/io/papermc/testplugin/TestPluginBootstrap.java
+++ b/test-plugin/src/main/java/io/papermc/testplugin/TestPluginBootstrap.java
@@ -1,7 +1,14 @@
 package io.papermc.testplugin;
 
+import io.papermc.paper.datapack.DatapackRegistrar;
 import io.papermc.paper.plugin.bootstrap.BootstrapContext;
 import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
+import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager;
+import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Objects;
 import org.jetbrains.annotations.NotNull;
 
 public class TestPluginBootstrap implements PluginBootstrap {
@@ -9,6 +16,16 @@ public class TestPluginBootstrap implements PluginBootstrap {
     @Override
     public void bootstrap(@NotNull BootstrapContext context) {
         // io.papermc.testplugin.brigtests.Registration.registerViaBootstrap(context);
+        final LifecycleEventManager<BootstrapContext> manager = context.getLifecycleManager();
+        manager.registerEventHandler(LifecycleEvents.DATAPACK_DISCOVERY, event -> {
+            final DatapackRegistrar registrar = event.registrar();
+            try {
+                final URI uri = Objects.requireNonNull(TestPluginBootstrap.class.getResource("/pack")).toURI();
+                registrar.discoverPack(uri, "test");
+            } catch (final URISyntaxException | IOException e) {
+                throw new RuntimeException(e);
+            }
+        });
     }
 
 }
diff --git a/test-plugin/src/main/resources/pack/data/machine_maker/tags/entity_type/test.json b/test-plugin/src/main/resources/pack/data/machine_maker/tags/entity_type/test.json
new file mode 100644
index 0000000000..36722a9a39
--- /dev/null
+++ b/test-plugin/src/main/resources/pack/data/machine_maker/tags/entity_type/test.json
@@ -0,0 +1,6 @@
+{
+    "values": [
+        "minecraft:zombie",
+        "minecraft:spider"
+    ]
+}
diff --git a/test-plugin/src/main/resources/pack/data/machine_maker/tags/item/test.json b/test-plugin/src/main/resources/pack/data/machine_maker/tags/item/test.json
new file mode 100644
index 0000000000..2a659578fa
--- /dev/null
+++ b/test-plugin/src/main/resources/pack/data/machine_maker/tags/item/test.json
@@ -0,0 +1,6 @@
+{
+    "values": [
+        "minecraft:stone_bricks",
+        "minecraft:string"
+    ]
+}
diff --git a/test-plugin/src/main/resources/pack/tags/pack.mcmeta b/test-plugin/src/main/resources/pack/tags/pack.mcmeta
new file mode 100644
index 0000000000..8a117a64fc
--- /dev/null
+++ b/test-plugin/src/main/resources/pack/tags/pack.mcmeta
@@ -0,0 +1,6 @@
+{
+  "pack":{
+    "pack_format": 41,
+    "description": "Test datapack for tags"
+  }
+}