diff --git a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java
index b4809edf2..a2797e6dd 100644
--- a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java
+++ b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java
@@ -182,6 +182,13 @@ public class GeyserItemStack {
         return session.getItemMappings().getMapping(this.javaId);
     }
 
+    public SlotDisplay asSlotDisplay() {
+        if (isEmpty()) {
+            return new EmptySlotDisplay();
+        }
+        return new ItemStackSlotDisplay(this.getItemStack());
+    }
+
     public Item asItem() {
         if (isEmpty()) {
             return Items.AIR;
diff --git a/core/src/main/java/org/geysermc/geyser/inventory/recipe/GeyserSmithingRecipe.java b/core/src/main/java/org/geysermc/geyser/inventory/recipe/GeyserSmithingRecipe.java
new file mode 100644
index 000000000..7e4131a4c
--- /dev/null
+++ b/core/src/main/java/org/geysermc/geyser/inventory/recipe/GeyserSmithingRecipe.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2024 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.inventory.recipe;
+
+import org.geysermc.mcprotocollib.protocol.data.game.recipe.display.SmithingRecipeDisplay;
+import org.geysermc.mcprotocollib.protocol.data.game.recipe.display.slot.SlotDisplay;
+
+public record GeyserSmithingRecipe(SlotDisplay template,
+                                   SlotDisplay base,
+                                   SlotDisplay addition,
+                                   SlotDisplay result) implements GeyserRecipe {
+    public GeyserSmithingRecipe(SmithingRecipeDisplay display) {
+        this(display.template(), display.base(), display.addition(), display.result());
+    }
+
+    @Override
+    public boolean isShaped() {
+        return false;
+    }
+}
diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
index 7e55261f4..ef8ad17ef 100644
--- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
+++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java
@@ -29,6 +29,7 @@ import com.google.gson.Gson;
 import com.google.gson.JsonObject;
 import io.netty.channel.Channel;
 import io.netty.channel.EventLoop;
+import it.unimi.dsi.fastutil.Pair;
 import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
 import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
 import it.unimi.dsi.fastutil.objects.Object2IntMap;
@@ -77,6 +78,7 @@ import org.cloudburstmc.protocol.bedrock.data.command.SoftEnumUpdateType;
 import org.cloudburstmc.protocol.bedrock.data.definitions.DimensionDefinition;
 import org.cloudburstmc.protocol.bedrock.data.entity.EntityFlag;
 import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData;
+import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.CraftingRecipeData;
 import org.cloudburstmc.protocol.bedrock.packet.AvailableEntityIdentifiersPacket;
 import org.cloudburstmc.protocol.bedrock.packet.BedrockPacket;
 import org.cloudburstmc.protocol.bedrock.packet.BiomeDefinitionListPacket;
@@ -141,6 +143,7 @@ import org.geysermc.geyser.impl.camera.GeyserCameraData;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.PlayerInventory;
 import org.geysermc.geyser.inventory.recipe.GeyserRecipe;
+import org.geysermc.geyser.inventory.recipe.GeyserSmithingRecipe;
 import org.geysermc.geyser.inventory.recipe.GeyserStonecutterData;
 import org.geysermc.geyser.item.Items;
 import org.geysermc.geyser.item.type.BlockItem;
@@ -311,7 +314,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
     private final AtomicInteger itemNetId = new AtomicInteger(2);
 
     @Setter
-    private ScheduledFuture<?> craftingGridFuture;
+    private ScheduledFuture<?> containerOutputFuture;
 
     /**
      * Stores session collision
@@ -447,6 +450,8 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
     private final Int2ObjectMap<List<String>> javaToBedrockRecipeIds;
 
     private final Int2ObjectMap<GeyserRecipe> craftingRecipes;
+    @Setter
+    private Pair<CraftingRecipeData, GeyserRecipe> lastCreatedRecipe = null; // TODO try to prevent sending duplicate recipes
     private final AtomicInteger lastRecipeNetId;
 
     /**
@@ -455,6 +460,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource {
      */
     @Setter
     private Int2ObjectMap<GeyserStonecutterData> stonecutterRecipes;
+    private final List<GeyserSmithingRecipe> smithingRecipes = new ArrayList<>();
 
     /**
      * Whether to work around 1.13's different behavior in villager trading menus.
diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/SmithingInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/SmithingInventoryTranslator.java
index c68347fd3..dbe24230a 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/inventory/SmithingInventoryTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/SmithingInventoryTranslator.java
@@ -33,6 +33,11 @@ import org.geysermc.geyser.inventory.updater.UIInventoryUpdater;
 import org.geysermc.geyser.level.block.Blocks;
 
 public class SmithingInventoryTranslator extends AbstractBlockInventoryTranslator {
+    public static final int TEMPLATE = 0;
+    public static final int INPUT = 1;
+    public static final int MATERIAL = 2;
+    public static final int OUTPUT = 3;
+
     public SmithingInventoryTranslator() {
         super(4, Blocks.SMITHING_TABLE, ContainerType.SMITHING_TABLE, UIInventoryUpdater.INSTANCE);
     }
@@ -40,10 +45,10 @@ public class SmithingInventoryTranslator extends AbstractBlockInventoryTranslato
     @Override
     public int bedrockSlotToJava(ItemStackRequestSlotData slotInfoData) {
         return switch (slotInfoData.getContainer()) {
-            case SMITHING_TABLE_TEMPLATE -> 0;
-            case SMITHING_TABLE_INPUT -> 1;
-            case SMITHING_TABLE_MATERIAL -> 2;
-            case SMITHING_TABLE_RESULT, CREATED_OUTPUT -> 3;
+            case SMITHING_TABLE_TEMPLATE -> TEMPLATE;
+            case SMITHING_TABLE_INPUT -> INPUT;
+            case SMITHING_TABLE_MATERIAL -> MATERIAL;
+            case SMITHING_TABLE_RESULT, CREATED_OUTPUT -> OUTPUT;
             default -> super.bedrockSlotToJava(slotInfoData);
         };
     }
@@ -51,10 +56,10 @@ public class SmithingInventoryTranslator extends AbstractBlockInventoryTranslato
     @Override
     public BedrockContainerSlot javaSlotToBedrockContainer(int slot) {
         return switch (slot) {
-            case 0 -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_TEMPLATE, 53);
-            case 1 -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_INPUT, 51);
-            case 2 -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_MATERIAL, 52);
-            case 3 -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_RESULT, 50);
+            case TEMPLATE -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_TEMPLATE, 53);
+            case INPUT -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_INPUT, 51);
+            case MATERIAL -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_MATERIAL, 52);
+            case OUTPUT -> new BedrockContainerSlot(ContainerSlotType.SMITHING_TABLE_RESULT, 50);
             default -> super.javaSlotToBedrockContainer(slot);
         };
     }
@@ -62,10 +67,10 @@ public class SmithingInventoryTranslator extends AbstractBlockInventoryTranslato
     @Override
     public int javaSlotToBedrock(int slot) {
         return switch (slot) {
-            case 0 -> 53;
-            case 1 -> 51;
-            case 2 -> 52;
-            case 3 -> 50;
+            case TEMPLATE -> 53;
+            case INPUT -> 51;
+            case MATERIAL -> 52;
+            case OUTPUT -> 50;
             default -> super.javaSlotToBedrock(slot);
         };
     }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaRecipeBookAddTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaRecipeBookAddTranslator.java
index da8fd01d3..f23ebebcb 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaRecipeBookAddTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaRecipeBookAddTranslator.java
@@ -42,6 +42,7 @@ import org.cloudburstmc.protocol.bedrock.packet.UnlockedRecipesPacket;
 import org.geysermc.geyser.inventory.recipe.GeyserRecipe;
 import org.geysermc.geyser.inventory.recipe.GeyserShapedRecipe;
 import org.geysermc.geyser.inventory.recipe.GeyserShapelessRecipe;
+import org.geysermc.geyser.inventory.recipe.GeyserSmithingRecipe;
 import org.geysermc.geyser.item.Items;
 import org.geysermc.geyser.item.type.BedrockRequiresTagItem;
 import org.geysermc.geyser.item.type.Item;
@@ -176,6 +177,8 @@ public class JavaRecipeBookAddTranslator extends PacketTranslator<ClientboundRec
                         }
                     }
                     javaToBedrockRecipeIds.put(contents.id(), bedrockRecipeIds);
+                    session.getSmithingRecipes().add(new GeyserSmithingRecipe(smithingRecipe));
+                    System.out.println(new GeyserSmithingRecipe(smithingRecipe));
                 }
             }
         }
diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java
index c98ab1ff6..8f5fab1f9 100644
--- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java
+++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java
@@ -29,6 +29,7 @@ import org.cloudburstmc.protocol.bedrock.data.inventory.ContainerId;
 import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData;
 import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.RecipeUnlockingRequirement;
 import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.ShapedRecipeData;
+import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.SmithingTransformRecipeData;
 import org.cloudburstmc.protocol.bedrock.data.inventory.descriptor.ItemDescriptorWithCount;
 import org.cloudburstmc.protocol.bedrock.packet.CraftingDataPacket;
 import org.cloudburstmc.protocol.bedrock.packet.InventorySlotPacket;
@@ -36,15 +37,17 @@ import org.geysermc.geyser.GeyserLogger;
 import org.geysermc.geyser.inventory.GeyserItemStack;
 import org.geysermc.geyser.inventory.Inventory;
 import org.geysermc.geyser.inventory.recipe.GeyserShapedRecipe;
+import org.geysermc.geyser.inventory.recipe.GeyserSmithingRecipe;
+import org.geysermc.geyser.item.Items;
 import org.geysermc.geyser.session.GeyserSession;
 import org.geysermc.geyser.translator.inventory.InventoryTranslator;
 import org.geysermc.geyser.translator.inventory.PlayerInventoryTranslator;
+import org.geysermc.geyser.translator.inventory.SmithingInventoryTranslator;
 import org.geysermc.geyser.translator.item.ItemTranslator;
 import org.geysermc.geyser.translator.protocol.PacketTranslator;
 import org.geysermc.geyser.translator.protocol.Translator;
 import org.geysermc.geyser.util.InventoryUtils;
 import org.geysermc.mcprotocollib.protocol.data.game.item.ItemStack;
-import org.geysermc.mcprotocollib.protocol.data.game.recipe.display.slot.EmptySlotDisplay;
 import org.geysermc.mcprotocollib.protocol.data.game.recipe.display.slot.ItemStackSlotDisplay;
 import org.geysermc.mcprotocollib.protocol.data.game.recipe.display.slot.SlotDisplay;
 import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.inventory.ClientboundContainerSetSlotPacket;
@@ -83,7 +86,11 @@ public class JavaContainerSetSlotTranslator extends PacketTranslator<Clientbound
                 return;
             }
 
-            updateCraftingGrid(session, slot, packet.getItem(), inventory, translator);
+            if (translator instanceof SmithingInventoryTranslator) {
+                updateSmithingTableOutput(session, slot, packet.getItem(), inventory);
+            } else {
+                updateCraftingGrid(session, slot, packet.getItem(), inventory, translator);
+            }
 
             GeyserItemStack newItem = GeyserItemStack.from(packet.getItem());
             if (packet.getContainerId() == 0 && !(translator instanceof PlayerInventoryTranslator)) {
@@ -119,15 +126,15 @@ public class JavaContainerSetSlotTranslator extends PacketTranslator<Clientbound
         }
 
         // Only process the most recent crafting grid result, and cancel the previous one.
-        if (session.getCraftingGridFuture() != null) {
-            session.getCraftingGridFuture().cancel(false);
+        if (session.getContainerOutputFuture() != null) {
+            session.getContainerOutputFuture().cancel(false);
         }
 
         if (InventoryUtils.isEmpty(item)) {
             return;
         }
 
-        session.setCraftingGridFuture(session.scheduleInEventLoop(() -> {
+        session.setContainerOutputFuture(session.scheduleInEventLoop(() -> {
             int offset = gridSize == 4 ? 28 : 32;
             int gridDimensions = gridSize == 4 ? 2 : 3;
             int firstRow = -1, height = -1;
@@ -172,7 +179,7 @@ public class JavaContainerSetSlotTranslator extends PacketTranslator<Clientbound
                 for (int col = firstCol; col < width + firstCol; col++) {
                     GeyserItemStack geyserItemStack = inventory.getItem(col + (row * gridDimensions) + 1);
                     ingredients[index] = geyserItemStack.getItemData(session);
-                    javaIngredients.add(geyserItemStack.isEmpty() ? new EmptySlotDisplay() : new ItemStackSlotDisplay(geyserItemStack.getItemStack()));
+                    javaIngredients.add(geyserItemStack.asSlotDisplay());
 
                     InventorySlotPacket slotPacket = new InventorySlotPacket();
                     slotPacket.setContainerId(ContainerId.UI);
@@ -216,4 +223,74 @@ public class JavaContainerSetSlotTranslator extends PacketTranslator<Clientbound
             }
         }, 150, TimeUnit.MILLISECONDS));
     }
+
+    private static void updateSmithingTableOutput(GeyserSession session, int slot, ItemStack output, Inventory inventory) {
+        if (slot != SmithingInventoryTranslator.OUTPUT) {
+            return;
+        }
+
+        // Only process the most recent output result, and cancel the previous one.
+        if (session.getContainerOutputFuture() != null) {
+            session.getContainerOutputFuture().cancel(false);
+        }
+
+        if (InventoryUtils.isEmpty(output)) {
+            return;
+        }
+
+        session.setContainerOutputFuture(session.scheduleInEventLoop(() -> {
+            GeyserItemStack template = inventory.getItem(SmithingInventoryTranslator.TEMPLATE);
+            if (template.asItem() != Items.NETHERITE_UPGRADE_SMITHING_TEMPLATE) {
+                // Technically we should probably also do this for custom items, but last I checked Bedrock doesn't even support that.
+                return;
+            }
+
+            GeyserItemStack input = inventory.getItem(SmithingInventoryTranslator.INPUT);
+            GeyserItemStack material = inventory.getItem(SmithingInventoryTranslator.MATERIAL);
+            GeyserItemStack geyserOutput = GeyserItemStack.from(output);
+
+            for (GeyserSmithingRecipe recipe : session.getSmithingRecipes()) {
+                if (InventoryUtils.acceptsAsInput(session, recipe.result(), geyserOutput)
+                && InventoryUtils.acceptsAsInput(session, recipe.base(), input)
+                && InventoryUtils.acceptsAsInput(session, recipe.addition(), material)
+                && InventoryUtils.acceptsAsInput(session, recipe.template(), template)) {
+                    // The client already recognizes this item.
+                    return;
+                }
+            }
+
+            session.getSmithingRecipes().add(new GeyserSmithingRecipe(
+                template.asSlotDisplay(),
+                input.asSlotDisplay(),
+                material.asSlotDisplay(),
+                new ItemStackSlotDisplay(output)
+            ));
+
+            UUID uuid = UUID.randomUUID();
+
+            ItemData bedrockAddition = ItemTranslator.translateToBedrock(session, material.getItemStack());
+
+            CraftingDataPacket craftPacket = new CraftingDataPacket();
+            craftPacket.getCraftingData().add(SmithingTransformRecipeData.of(
+                uuid.toString(),
+                ItemDescriptorWithCount.fromItem(ItemTranslator.translateToBedrock(session, template.getItemStack())),
+                ItemDescriptorWithCount.fromItem(ItemTranslator.translateToBedrock(session, input.getItemStack())),
+                ItemDescriptorWithCount.fromItem(bedrockAddition),
+                ItemTranslator.translateToBedrock(session, output),
+                "smithing_table",
+                session.getLastRecipeNetId().incrementAndGet()
+            ));
+            craftPacket.setCleanRecipes(false);
+            session.sendUpstreamPacket(craftPacket);
+
+            // Just set one of the slots to air, then right back to its proper item.
+            InventorySlotPacket slotPacket = new InventorySlotPacket();
+            slotPacket.setContainerId(ContainerId.UI);
+            slotPacket.setSlot(session.getInventoryTranslator().javaSlotToBedrock(SmithingInventoryTranslator.MATERIAL));
+            slotPacket.setItem(ItemData.AIR);
+            session.sendUpstreamPacket(slotPacket);
+
+            session.getInventoryTranslator().updateSlot(session, inventory, SmithingInventoryTranslator.MATERIAL);
+        }, 150, TimeUnit.MILLISECONDS));
+    }
 }