diff --git a/fabric-data-generation-api-v1/src/main/java/net/fabricmc/fabric/api/datagen/v1/provider/FabricDynamicRegistryProvider.java b/fabric-data-generation-api-v1/src/main/java/net/fabricmc/fabric/api/datagen/v1/provider/FabricDynamicRegistryProvider.java
index 71588502d..08c510757 100644
--- a/fabric-data-generation-api-v1/src/main/java/net/fabricmc/fabric/api/datagen/v1/provider/FabricDynamicRegistryProvider.java
+++ b/fabric-data-generation-api-v1/src/main/java/net/fabricmc/fabric/api/datagen/v1/provider/FabricDynamicRegistryProvider.java
@@ -51,6 +51,7 @@ import net.minecraft.world.gen.carver.ConfiguredCarver;
 import net.minecraft.world.gen.feature.PlacedFeature;
 
 import net.fabricmc.fabric.api.datagen.v1.FabricDataOutput;
+import net.fabricmc.fabric.api.event.registry.DynamicRegistries;
 
 /**
  * A provider to help with data-generation of dynamic registry objects,
@@ -79,7 +80,7 @@ public abstract class FabricDynamicRegistryProvider implements DataProvider {
 		@ApiStatus.Internal
 		Entries(RegistryWrapper.WrapperLookup registries, String modId) {
 			this.registries = registries;
-			this.queuedEntries = RegistryLoader.DYNAMIC_REGISTRIES.stream()
+			this.queuedEntries = DynamicRegistries.getDynamicRegistries().stream()
 					.collect(Collectors.toMap(
 							e -> e.key().getValue(),
 							e -> RegistryEntries.create(registries, e)
diff --git a/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/api/event/registry/DynamicRegistries.java b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/api/event/registry/DynamicRegistries.java
new file mode 100644
index 000000000..1c52338ca
--- /dev/null
+++ b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/api/event/registry/DynamicRegistries.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.api.event.registry;
+
+import java.util.List;
+
+import com.mojang.serialization.Codec;
+import org.jetbrains.annotations.Unmodifiable;
+
+import net.minecraft.registry.Registry;
+import net.minecraft.registry.RegistryKey;
+import net.minecraft.registry.RegistryLoader;
+
+import net.fabricmc.fabric.impl.registry.sync.DynamicRegistriesImpl;
+
+/**
+ * Contains methods for registering and accessing dynamic {@linkplain Registry registries}.
+ *
+ * <h2>Basic usage</h2>
+ * Custom dynamic registries can be registered with {@link #register(RegistryKey, Codec)}. These registries will not be
+ * <a href="#sync">synced to the client</a>.
+ *
+ * <p>The list of all dynamic registries, whether from vanilla or mods, can be accessed using
+ * {@link #getDynamicRegistries()}.
+ *
+ * <h2 id="sync">Synchronization</h2>
+ * Dynamic registries are not synchronized to the client by default.
+ * To register a <em>synced dynamic registry</em>, you can replace the {@link #register} call
+ * with a call to {@link #registerSynced(RegistryKey, Codec, SyncOption...)}.
+ *
+ * <p>If you want to use a different codec for syncing, e.g. to skip unnecessary data,
+ * you can use the overload with two codecs: {@link #registerSynced(RegistryKey, Codec, Codec, SyncOption...)}.
+ *
+ * <p>Synced dynamic registries can also be prevented from syncing if they have no entries.
+ * This is useful for compatibility with clients that might not have your dynamic registry.
+ * This behavior can be enabled by passing the {@link SyncOption#SKIP_WHEN_EMPTY} flag to {@code registerSynced}.
+ *
+ * <h2>Examples</h2>
+ * {@snippet :
+ * // @link region substring=RegistryKey target=RegistryKey
+ * // @link region substring=ofRegistry target="RegistryKey#ofRegistry"
+ * // @link region substring=Identifier target="net.minecraft.util.Identifier#Identifier(String, String)"
+ * public static final RegistryKey<Registry<MyData>> MY_DATA_KEY = RegistryKey.ofRegistry(new Identifier("my_mod", "my_data"));
+ * // @end @end @end
+ *
+ * // Option 1: Register a non-synced registry
+ * // @link substring=register target="#register":
+ * DynamicRegistries.register(MY_DATA_KEY, MyData.CODEC);
+ *
+ * // Option 2a: Register a synced registry
+ * // @link substring=registerSynced target="#registerSynced(RegistryKey, Codec, SyncOption...)":
+ * DynamicRegistries.registerSynced(MY_DATA_KEY, MyData.CODEC);
+ *
+ * // Option 2b: Register a synced registry with a different network codec
+ * // @link substring=registerSynced target="#registerSynced(RegistryKey, Codec, Codec, SyncOption...)":
+ * DynamicRegistries.registerSynced(MY_DATA_KEY, MyData.CODEC, MyData.NETWORK_CODEC);
+ * }
+ */
+public final class DynamicRegistries {
+	private DynamicRegistries() {
+	}
+
+	/**
+	 * Returns an unmodifiable list of all dynamic registries, including modded ones.
+	 *
+	 * <p>The list will not reflect any changes caused by later registrations.
+	 *
+	 * @return an unmodifiable list of all dynamic registries
+	 */
+	public static @Unmodifiable List<RegistryLoader.Entry<?>> getDynamicRegistries() {
+		return DynamicRegistriesImpl.getDynamicRegistries();
+	}
+
+	/**
+	 * Registers a non-synced dynamic registry.
+	 *
+	 * <p>The entries of the registry will be loaded from data packs at the file path
+	 * {@code data/<entry namespace>/<registry namespace>/<registry path>/<entry path>.json}.
+	 *
+	 * @param key   the unique key of the registry
+	 * @param codec the codec used to load registry entries from data packs
+	 * @param <T>   the entry type of the registry
+	 */
+	public static <T> void register(RegistryKey<? extends Registry<T>> key, Codec<T> codec) {
+		DynamicRegistriesImpl.register(key, codec);
+	}
+
+	/**
+	 * Registers a synced dynamic registry.
+	 *
+	 * <p>The entries of the registry will be loaded from data packs at the file path
+	 * {@code data/<entry namespace>/<registry namespace>/<registry path>/<entry path>.json}.
+	 *
+	 * <p>The registry will be synced from the server to players' clients using the same codec
+	 * that is used to load the registry.
+	 *
+	 * <p>If the object contained in the registry is complex and contains a lot of data
+	 * that is not relevant on the client, another codec for networking can be specified with
+	 * {@link #registerSynced(RegistryKey, Codec, Codec, SyncOption...)}.
+	 *
+	 * @param key     the unique key of the registry
+	 * @param codec   the codec used to load registry entries from data packs and the network
+	 * @param options options to configure syncing
+	 * @param <T>   the entry type of the registry
+	 */
+	public static <T> void registerSynced(RegistryKey<? extends Registry<T>> key, Codec<T> codec, SyncOption... options) {
+		registerSynced(key, codec, codec, options);
+	}
+
+	/**
+	 * Registers a synced dynamic registry.
+	 *
+	 * <p>The entries of the registry will be loaded from data packs at the file path
+	 * {@code data/<entry namespace>/<registry namespace>/<registry path>/<entry path>.json}
+	 *
+	 * <p>The registry will be synced from the server to players' clients using the given network codec.
+	 *
+	 * @param key          the unique key of the registry
+	 * @param dataCodec    the codec used to load registry entries from data packs
+	 * @param networkCodec the codec used to load registry entries from the network
+	 * @param options      options to configure syncing
+	 * @param <T>          the entry type of the registry
+	 */
+	public static <T> void registerSynced(RegistryKey<? extends Registry<T>> key, Codec<T> dataCodec, Codec<T> networkCodec, SyncOption... options) {
+		DynamicRegistriesImpl.register(key, dataCodec);
+		DynamicRegistriesImpl.addSyncedRegistry(key, networkCodec, options);
+	}
+
+	/**
+	 * Flags for configuring dynamic registry syncing.
+	 */
+	public enum SyncOption {
+		/**
+		 * Only synchronizes the dynamic registry if it's not empty.
+		 * This is useful for compatibility with vanilla clients,
+		 * or other clients that might not have the registry.
+		 */
+		SKIP_WHEN_EMPTY
+	}
+}
diff --git a/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/impl/registry/sync/DynamicRegistriesImpl.java b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/impl/registry/sync/DynamicRegistriesImpl.java
new file mode 100644
index 000000000..dacbb565d
--- /dev/null
+++ b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/impl/registry/sync/DynamicRegistriesImpl.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.impl.registry.sync;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import com.mojang.serialization.Codec;
+import org.jetbrains.annotations.Unmodifiable;
+
+import net.minecraft.registry.Registry;
+import net.minecraft.registry.RegistryKey;
+import net.minecraft.registry.RegistryLoader;
+import net.minecraft.registry.SerializableRegistries;
+
+import net.fabricmc.fabric.api.event.registry.DynamicRegistries;
+
+public final class DynamicRegistriesImpl {
+	private static final List<RegistryLoader.Entry<?>> DYNAMIC_REGISTRIES = new ArrayList<>(RegistryLoader.DYNAMIC_REGISTRIES);
+	public static final Set<RegistryKey<? extends Registry<?>>> DYNAMIC_REGISTRY_KEYS = new HashSet<>();
+	public static final Set<RegistryKey<? extends Registry<?>>> SKIP_EMPTY_SYNC_REGISTRIES = new HashSet<>();
+
+	static {
+		for (RegistryLoader.Entry<?> vanillaEntry : RegistryLoader.DYNAMIC_REGISTRIES) {
+			DYNAMIC_REGISTRY_KEYS.add(vanillaEntry.key());
+		}
+	}
+
+	private DynamicRegistriesImpl() {
+	}
+
+	public static @Unmodifiable List<RegistryLoader.Entry<?>> getDynamicRegistries() {
+		return List.copyOf(DYNAMIC_REGISTRIES);
+	}
+
+	public static <T> void register(RegistryKey<? extends Registry<T>> key, Codec<T> codec) {
+		Objects.requireNonNull(key, "Registry key cannot be null");
+		Objects.requireNonNull(codec, "Codec cannot be null");
+
+		if (!DYNAMIC_REGISTRY_KEYS.add(key)) {
+			throw new IllegalArgumentException("Dynamic registry " + key + " has already been registered!");
+		}
+
+		var entry = new RegistryLoader.Entry<>(key, codec);
+		DYNAMIC_REGISTRIES.add(entry);
+	}
+
+	public static <T> void addSyncedRegistry(RegistryKey<? extends Registry<T>> registryKey, Codec<T> networkCodec, DynamicRegistries.SyncOption... options) {
+		Objects.requireNonNull(registryKey, "Registry key cannot be null");
+		Objects.requireNonNull(networkCodec, "Network codec cannot be null");
+		Objects.requireNonNull(options, "Options cannot be null");
+
+		if (!(SerializableRegistries.REGISTRIES instanceof HashMap<?, ?>)) {
+			SerializableRegistries.REGISTRIES = new HashMap<>(SerializableRegistries.REGISTRIES);
+		}
+
+		SerializableRegistries.REGISTRIES.put(registryKey, new SerializableRegistries.Info<>(registryKey, networkCodec));
+
+		for (DynamicRegistries.SyncOption option : options) {
+			if (option == DynamicRegistries.SyncOption.SKIP_WHEN_EMPTY) {
+				SKIP_EMPTY_SYNC_REGISTRIES.add(registryKey);
+			}
+		}
+	}
+}
diff --git a/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/RegistryLoaderMixin.java b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/RegistryLoaderMixin.java
index 5486ea43f..33f93359e 100644
--- a/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/RegistryLoaderMixin.java
+++ b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/RegistryLoaderMixin.java
@@ -34,6 +34,7 @@ import net.minecraft.registry.RegistryKey;
 import net.minecraft.registry.RegistryLoader;
 import net.minecraft.registry.RegistryOps;
 import net.minecraft.resource.ResourceManager;
+import net.minecraft.util.Identifier;
 
 import net.fabricmc.fabric.api.event.registry.DynamicRegistrySetupCallback;
 import net.fabricmc.fabric.impl.registry.sync.DynamicRegistryViewImpl;
@@ -58,4 +59,14 @@ public class RegistryLoaderMixin {
 
 		DynamicRegistrySetupCallback.EVENT.invoker().onRegistrySetup(new DynamicRegistryViewImpl(registries));
 	}
+
+	// Vanilla doesn't mark namespaces in the directories of dynamic registries at all,
+	// so we prepend the directories with the namespace if it's a modded registry id.
+	@Inject(method = "getPath", at = @At("RETURN"), cancellable = true)
+	private static void prependDirectoryWithNamespace(Identifier id, CallbackInfoReturnable<String> info) {
+		if (!id.getNamespace().equals(Identifier.DEFAULT_NAMESPACE)) {
+			String newPath = id.getNamespace() + "/" + info.getReturnValue();
+			info.setReturnValue(newPath);
+		}
+	}
 }
diff --git a/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/SaveLoadingMixin.java b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/SaveLoadingMixin.java
new file mode 100644
index 000000000..1366f2ea7
--- /dev/null
+++ b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/SaveLoadingMixin.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.registry.sync;
+
+import java.util.List;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.ModifyArg;
+
+import net.minecraft.registry.RegistryLoader;
+import net.minecraft.server.SaveLoading;
+
+import net.fabricmc.fabric.api.event.registry.DynamicRegistries;
+
+// Implements dynamic registry loading.
+@Mixin(SaveLoading.class)
+abstract class SaveLoadingMixin {
+	@ModifyArg(method = "load", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/SaveLoading;withRegistriesLoaded(Lnet/minecraft/resource/ResourceManager;Lnet/minecraft/registry/CombinedDynamicRegistries;Lnet/minecraft/registry/ServerDynamicRegistryType;Ljava/util/List;)Lnet/minecraft/registry/CombinedDynamicRegistries;"), allow = 1)
+	private static List<RegistryLoader.Entry<?>> modifyLoadedEntries(List<RegistryLoader.Entry<?>> entries) {
+		return DynamicRegistries.getDynamicRegistries();
+	}
+}
diff --git a/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/SerializableRegistriesMixin.java b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/SerializableRegistriesMixin.java
new file mode 100644
index 000000000..3224d62a3
--- /dev/null
+++ b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/SerializableRegistriesMixin.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.registry.sync;
+
+import java.util.stream.Stream;
+
+import org.spongepowered.asm.mixin.Dynamic;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Redirect;
+
+import net.minecraft.registry.DynamicRegistryManager;
+import net.minecraft.registry.SerializableRegistries;
+
+import net.fabricmc.fabric.impl.registry.sync.DynamicRegistriesImpl;
+
+// Implements skipping empty dynamic registries with the SKIP_WHEN_EMPTY sync option.
+@Mixin(SerializableRegistries.class)
+abstract class SerializableRegistriesMixin {
+	@Shadow
+	private static Stream<DynamicRegistryManager.Entry<?>> stream(DynamicRegistryManager dynamicRegistryManager) {
+		return null;
+	}
+
+	@Dynamic("method_45961: Codec.xmap in createDynamicRegistryManagerCodec")
+	@Redirect(method = "method_45961", at = @At(value = "INVOKE", target = "Lnet/minecraft/registry/SerializableRegistries;stream(Lnet/minecraft/registry/DynamicRegistryManager;)Ljava/util/stream/Stream;"))
+	private static Stream<DynamicRegistryManager.Entry<?>> filterNonSyncedEntries(DynamicRegistryManager drm) {
+		return stream(drm).filter(entry -> {
+			boolean canSkip = DynamicRegistriesImpl.SKIP_EMPTY_SYNC_REGISTRIES.contains(entry.key());
+			return !canSkip || entry.value().size() > 0;
+		});
+	}
+}
diff --git a/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/TagManagerLoaderMixin.java b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/TagManagerLoaderMixin.java
new file mode 100644
index 000000000..2e1b78fdd
--- /dev/null
+++ b/fabric-registry-sync-v0/src/main/java/net/fabricmc/fabric/mixin/registry/sync/TagManagerLoaderMixin.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.mixin.registry.sync;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+import net.minecraft.registry.Registry;
+import net.minecraft.registry.RegistryKey;
+import net.minecraft.registry.tag.TagManagerLoader;
+import net.minecraft.util.Identifier;
+
+import net.fabricmc.fabric.impl.registry.sync.DynamicRegistriesImpl;
+
+// Adds namespaces to tag directories for registries added by mods.
+@Mixin(TagManagerLoader.class)
+abstract class TagManagerLoaderMixin {
+	@Inject(method = "getPath", at = @At("HEAD"), cancellable = true)
+	private static void onGetPath(RegistryKey<? extends Registry<?>> registry, CallbackInfoReturnable<String> info) {
+		// TODO: Expand this change to static registries in the future.
+		if (!DynamicRegistriesImpl.DYNAMIC_REGISTRY_KEYS.contains(registry)) {
+			return;
+		}
+
+		Identifier id = registry.getValue();
+
+		// Vanilla doesn't mark namespaces in the directories of tags at all,
+		// so we prepend the directories with the namespace if it's a modded registry id.
+		if (!id.getNamespace().equals(Identifier.DEFAULT_NAMESPACE)) {
+			info.setReturnValue("tags/" + id.getNamespace() + "/" + id.getPath());
+		}
+	}
+}
diff --git a/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.accesswidener b/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.accesswidener
index 62858b64b..18f96ed90 100644
--- a/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.accesswidener
+++ b/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.accesswidener
@@ -3,3 +3,8 @@ accessWidener	v2	named
 accessible    field    net/minecraft/registry/SimpleRegistry frozen Z
 accessible method net/minecraft/registry/entry/RegistryEntry$Reference setValue (Ljava/lang/Object;)V
 accessible    method    net/minecraft/registry/Registries    init    ()V
+
+accessible class net/minecraft/registry/SerializableRegistries$Info
+accessible method net/minecraft/registry/SerializableRegistries$Info <init> (Lnet/minecraft/registry/RegistryKey;Lcom/mojang/serialization/Codec;)V
+accessible field net/minecraft/registry/SerializableRegistries REGISTRIES Ljava/util/Map;
+mutable field net/minecraft/registry/SerializableRegistries REGISTRIES Ljava/util/Map;
diff --git a/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.mixins.json b/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.mixins.json
index 5d4dc01cf..f028c3ad4 100644
--- a/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.mixins.json
+++ b/fabric-registry-sync-v0/src/main/resources/fabric-registry-sync-v0.mixins.json
@@ -12,8 +12,11 @@
     "RegistriesAccessor",
     "RegistriesMixin",
     "RegistryLoaderMixin",
+    "SaveLoadingMixin",
+    "SerializableRegistriesMixin",
     "SimpleRegistryMixin",
-    "StructuresToConfiguredStructuresFixMixin"
+    "StructuresToConfiguredStructuresFixMixin",
+    "TagManagerLoaderMixin"
   ],
   "injectors": {
     "defaultRequire": 1
diff --git a/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/CustomDynamicRegistryTest.java b/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/CustomDynamicRegistryTest.java
new file mode 100644
index 000000000..1971cf7e9
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/CustomDynamicRegistryTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.registry.sync;
+
+import com.mojang.logging.LogUtils;
+import org.slf4j.Logger;
+
+import net.minecraft.registry.Registry;
+import net.minecraft.registry.RegistryKey;
+import net.minecraft.registry.entry.RegistryEntry;
+import net.minecraft.registry.tag.TagKey;
+import net.minecraft.util.Identifier;
+
+import net.fabricmc.api.ModInitializer;
+import net.fabricmc.fabric.api.event.lifecycle.v1.CommonLifecycleEvents;
+import net.fabricmc.fabric.api.event.registry.DynamicRegistries;
+import net.fabricmc.fabric.api.event.registry.DynamicRegistrySetupCallback;
+import net.fabricmc.fabric.api.event.registry.DynamicRegistryView;
+
+public final class CustomDynamicRegistryTest implements ModInitializer {
+	private static final Logger LOGGER = LogUtils.getLogger();
+
+	public static final RegistryKey<Registry<TestDynamicObject>> TEST_DYNAMIC_REGISTRY_KEY =
+			RegistryKey.ofRegistry(new Identifier("fabric", "test_dynamic"));
+	public static final RegistryKey<Registry<TestNestedDynamicObject>> TEST_NESTED_DYNAMIC_REGISTRY_KEY =
+			RegistryKey.ofRegistry(new Identifier("fabric", "test_dynamic_nested"));
+	public static final RegistryKey<Registry<TestDynamicObject>> TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY =
+			RegistryKey.ofRegistry(new Identifier("fabric", "test_dynamic_synced_1"));
+	public static final RegistryKey<Registry<TestDynamicObject>> TEST_SYNCED_2_DYNAMIC_REGISTRY_KEY =
+			RegistryKey.ofRegistry(new Identifier("fabric", "test_dynamic_synced_2"));
+	public static final RegistryKey<Registry<TestDynamicObject>> TEST_EMPTY_SYNCED_DYNAMIC_REGISTRY_KEY =
+			RegistryKey.ofRegistry(new Identifier("fabric", "test_dynamic_synced_empty"));
+
+	private static final RegistryKey<TestDynamicObject> SYNCED_ENTRY_KEY =
+			RegistryKey.of(TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY, new Identifier("fabric-registry-sync-v0-testmod", "synced"));
+	private static final TagKey<TestDynamicObject> TEST_DYNAMIC_OBJECT_TAG =
+			TagKey.of(TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY, new Identifier("fabric-registry-sync-v0-testmod", "test"));
+
+	@Override
+	public void onInitialize() {
+		DynamicRegistries.register(TEST_DYNAMIC_REGISTRY_KEY, TestDynamicObject.CODEC);
+		DynamicRegistries.registerSynced(TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY, TestDynamicObject.CODEC);
+		DynamicRegistries.registerSynced(TEST_SYNCED_2_DYNAMIC_REGISTRY_KEY, TestDynamicObject.CODEC, TestDynamicObject.NETWORK_CODEC);
+		DynamicRegistries.registerSynced(TEST_NESTED_DYNAMIC_REGISTRY_KEY, TestNestedDynamicObject.CODEC);
+		DynamicRegistries.registerSynced(TEST_EMPTY_SYNCED_DYNAMIC_REGISTRY_KEY, TestDynamicObject.CODEC, DynamicRegistries.SyncOption.SKIP_WHEN_EMPTY);
+
+		DynamicRegistrySetupCallback.EVENT.register(registryView -> {
+			addListenerForDynamic(registryView, TEST_DYNAMIC_REGISTRY_KEY);
+			addListenerForDynamic(registryView, TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY);
+			addListenerForDynamic(registryView, TEST_SYNCED_2_DYNAMIC_REGISTRY_KEY);
+			addListenerForDynamic(registryView, TEST_NESTED_DYNAMIC_REGISTRY_KEY);
+		});
+
+		CommonLifecycleEvents.TAGS_LOADED.register((registries, client) -> {
+			// Check that the tag has applied
+			RegistryEntry.Reference<TestDynamicObject> entry = registries.get(TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY)
+					.getEntry(SYNCED_ENTRY_KEY)
+					.orElseThrow();
+
+			if (!entry.isIn(TEST_DYNAMIC_OBJECT_TAG)) {
+				throw new AssertionError("Required dynamic registry entry is not in the expected tag! client: " + client);
+			}
+
+			LOGGER.info("Found {} in tag {} (client: {})", entry, TEST_DYNAMIC_OBJECT_TAG, client);
+		});
+	}
+
+	private static void addListenerForDynamic(DynamicRegistryView registryView, RegistryKey<? extends Registry<?>> key) {
+		registryView.registerEntryAdded(key, (rawId, id, object) -> {
+			LOGGER.info("Loaded entry of {}: {} = {}", key, id, object);
+		});
+	}
+}
diff --git a/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/TestDynamicObject.java b/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/TestDynamicObject.java
new file mode 100644
index 000000000..403ed57c4
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/TestDynamicObject.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.registry.sync;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+public record TestDynamicObject(String name, boolean usesNetworkCodec) {
+	public static final Codec<TestDynamicObject> CODEC = codec(false);
+	public static final Codec<TestDynamicObject> NETWORK_CODEC = codec(true);
+
+	private static Codec<TestDynamicObject> codec(boolean networkCodec) {
+		return RecordCodecBuilder.create(instance -> instance.group(
+				Codec.STRING.fieldOf("name").forGetter(TestDynamicObject::name)
+		).apply(instance, name -> new TestDynamicObject(name, networkCodec)));
+	}
+}
diff --git a/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/TestNestedDynamicObject.java b/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/TestNestedDynamicObject.java
new file mode 100644
index 000000000..572ea4a13
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/java/net/fabricmc/fabric/test/registry/sync/TestNestedDynamicObject.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.registry.sync;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.registry.entry.RegistryElementCodec;
+import net.minecraft.registry.entry.RegistryEntry;
+
+public record TestNestedDynamicObject(RegistryEntry<TestDynamicObject> nested) {
+	public static final Codec<TestNestedDynamicObject> CODEC = RecordCodecBuilder.create(instance -> instance.group(
+			RegistryElementCodec.of(CustomDynamicRegistryTest.TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY, TestDynamicObject.CODEC)
+					.fieldOf("nested")
+					.forGetter(TestNestedDynamicObject::nested)
+	).apply(instance, TestNestedDynamicObject::new));
+}
diff --git a/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic/first.json b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic/first.json
new file mode 100644
index 000000000..23ada4c50
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic/first.json
@@ -0,0 +1,3 @@
+{
+  "name": "First"
+}
diff --git a/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic/second.json b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic/second.json
new file mode 100644
index 000000000..5e5eddfe3
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic/second.json
@@ -0,0 +1,3 @@
+{
+  "name": "Second"
+}
diff --git a/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_nested/synced.json b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_nested/synced.json
new file mode 100644
index 000000000..fc51ec72d
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_nested/synced.json
@@ -0,0 +1,3 @@
+{
+  "nested": "fabric-registry-sync-v0-testmod:synced"
+}
diff --git a/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_synced_1/synced.json b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_synced_1/synced.json
new file mode 100644
index 000000000..3bddd0ded
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_synced_1/synced.json
@@ -0,0 +1,3 @@
+{
+  "name": "Synced #1"
+}
diff --git a/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_synced_2/synced.json b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_synced_2/synced.json
new file mode 100644
index 000000000..80e67782f
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/fabric/test_dynamic_synced_2/synced.json
@@ -0,0 +1,3 @@
+{
+  "name": "Synced #2"
+}
diff --git a/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/tags/fabric/test_dynamic_synced_1/test.json b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/tags/fabric/test_dynamic_synced_1/test.json
new file mode 100644
index 000000000..d892584a3
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmod/resources/data/fabric-registry-sync-v0-testmod/tags/fabric/test_dynamic_synced_1/test.json
@@ -0,0 +1,6 @@
+{
+  "replace": false,
+  "values": [
+    "fabric-registry-sync-v0-testmod:synced"
+  ]
+}
diff --git a/fabric-registry-sync-v0/src/testmod/resources/fabric.mod.json b/fabric-registry-sync-v0/src/testmod/resources/fabric.mod.json
index 93c6c7229..bd89658c1 100644
--- a/fabric-registry-sync-v0/src/testmod/resources/fabric.mod.json
+++ b/fabric-registry-sync-v0/src/testmod/resources/fabric.mod.json
@@ -10,7 +10,11 @@
   },
   "entrypoints": {
     "main": [
+      "net.fabricmc.fabric.test.registry.sync.CustomDynamicRegistryTest",
       "net.fabricmc.fabric.test.registry.sync.RegistrySyncTest"
+    ],
+    "client": [
+      "net.fabricmc.fabric.test.registry.sync.client.DynamicRegistryClientTest"
     ]
   }
 }
diff --git a/fabric-registry-sync-v0/src/testmodClient/java/net/fabricmc/fabric/test/registry/sync/client/DynamicRegistryClientTest.java b/fabric-registry-sync-v0/src/testmodClient/java/net/fabricmc/fabric/test/registry/sync/client/DynamicRegistryClientTest.java
new file mode 100644
index 000000000..b72d033ce
--- /dev/null
+++ b/fabric-registry-sync-v0/src/testmodClient/java/net/fabricmc/fabric/test/registry/sync/client/DynamicRegistryClientTest.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2016, 2017, 2018, 2019 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.fabric.test.registry.sync.client;
+
+import static net.fabricmc.fabric.test.registry.sync.CustomDynamicRegistryTest.TEST_EMPTY_SYNCED_DYNAMIC_REGISTRY_KEY;
+import static net.fabricmc.fabric.test.registry.sync.CustomDynamicRegistryTest.TEST_NESTED_DYNAMIC_REGISTRY_KEY;
+import static net.fabricmc.fabric.test.registry.sync.CustomDynamicRegistryTest.TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY;
+import static net.fabricmc.fabric.test.registry.sync.CustomDynamicRegistryTest.TEST_SYNCED_2_DYNAMIC_REGISTRY_KEY;
+
+import com.mojang.logging.LogUtils;
+import org.slf4j.Logger;
+
+import net.minecraft.registry.Registry;
+import net.minecraft.registry.RegistryKey;
+import net.minecraft.util.Identifier;
+
+import net.fabricmc.api.ClientModInitializer;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+import net.fabricmc.fabric.test.registry.sync.TestDynamicObject;
+import net.fabricmc.fabric.test.registry.sync.TestNestedDynamicObject;
+
+public final class DynamicRegistryClientTest implements ClientModInitializer {
+	private static final Logger LOGGER = LogUtils.getLogger();
+	private static final Identifier SYNCED_ID = new Identifier("fabric-registry-sync-v0-testmod", "synced");
+
+	@Override
+	public void onInitializeClient() {
+		ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> {
+			LOGGER.info("Starting dynamic registry sync tests...");
+
+			TestDynamicObject synced1 = handler.getRegistryManager()
+					.get(TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY)
+					.get(SYNCED_ID);
+			TestDynamicObject synced2 = handler.getRegistryManager()
+					.get(TEST_SYNCED_2_DYNAMIC_REGISTRY_KEY)
+					.get(SYNCED_ID);
+			TestNestedDynamicObject simpleNested = handler.getRegistryManager()
+					.get(TEST_NESTED_DYNAMIC_REGISTRY_KEY)
+					.get(SYNCED_ID);
+
+			LOGGER.info("Synced - simple: {}", synced1);
+			LOGGER.info("Synced - custom network codec: {}", synced2);
+			LOGGER.info("Synced - simple nested: {}", simpleNested);
+
+			if (synced1 == null) {
+				didNotReceive(TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY, SYNCED_ID);
+			}
+
+			if (synced1.usesNetworkCodec()) {
+				throw new AssertionError("Entries in " + TEST_SYNCED_1_DYNAMIC_REGISTRY_KEY + " should not use network codec");
+			}
+
+			if (synced2 == null) {
+				didNotReceive(TEST_SYNCED_2_DYNAMIC_REGISTRY_KEY, SYNCED_ID);
+			}
+
+			// The client server check is needed since the registries are passed through in singleplayer.
+			// The network codec flag would always be false in those cases.
+			if (client.getServer() == null && !synced2.usesNetworkCodec()) {
+				throw new AssertionError("Entries in " + TEST_SYNCED_2_DYNAMIC_REGISTRY_KEY + " should use network codec");
+			}
+
+			if (simpleNested == null) {
+				didNotReceive(TEST_NESTED_DYNAMIC_REGISTRY_KEY, SYNCED_ID);
+			}
+
+			if (simpleNested.nested().value() != synced1) {
+				throw new AssertionError("Did not match up synced nested entry to the other synced value");
+			}
+
+			// If the registries weren't passed through in SP, check that the empty registry was skipped.
+			if (client.getServer() == null && handler.getRegistryManager().getOptional(TEST_EMPTY_SYNCED_DYNAMIC_REGISTRY_KEY).isPresent()) {
+				throw new AssertionError("Received empty registry that should have been skipped");
+			}
+
+			LOGGER.info("Dynamic registry sync tests passed!");
+		});
+	}
+
+	private static void didNotReceive(RegistryKey<? extends Registry<?>> registryKey, Identifier entryId) {
+		throw new AssertionError("Did not receive " + registryKey + "/" + entryId);
+	}
+}