diff --git a/internal/plugin/database.go b/internal/plugin/database.go
new file mode 100644
index 0000000..d5d6f60
--- /dev/null
+++ b/internal/plugin/database.go
@@ -0,0 +1,54 @@
+package plugin
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+)
+
+const databaseFile = pluginsFolder + "/plugins.json"
+
+var pluginDatabase = PluginDatabase{}
+
+func init() {
+	if err := pluginDatabase.Load(); err != nil {
+		fmt.Printf("failed to load plugin database: %v\n", err)
+	}
+}
+
+func (d *PluginDatabase) Load() error {
+	file, err := os.Open(databaseFile)
+	if os.IsNotExist(err) {
+		d.Plugins = make(map[string]PluginInstall)
+		return nil
+	}
+	if err != nil {
+		return fmt.Errorf("failed to open plugin database: %v", err)
+	}
+	defer file.Close()
+
+	if err := json.NewDecoder(file).Decode(d); err != nil {
+		return fmt.Errorf("failed to decode plugin database: %v", err)
+	}
+
+	return nil
+}
+
+func (d *PluginDatabase) Save() error {
+	d.saveMutex.Lock()
+	defer d.saveMutex.Unlock()
+
+	file, err := os.Create(databaseFile)
+	if err != nil {
+		return fmt.Errorf("failed to create plugin database: %v", err)
+	}
+	defer file.Close()
+
+	encoder := json.NewEncoder(file)
+	encoder.SetIndent("", "  ")
+	if err := encoder.Encode(d); err != nil {
+		return fmt.Errorf("failed to encode plugin database: %v", err)
+	}
+
+	return nil
+}
diff --git a/internal/plugin/extract.go b/internal/plugin/extract.go
new file mode 100644
index 0000000..45508e9
--- /dev/null
+++ b/internal/plugin/extract.go
@@ -0,0 +1,83 @@
+package plugin
+
+import (
+	"archive/tar"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+
+	"github.com/google/uuid"
+)
+
+const pluginsExtractsFolder = pluginsFolder + "/extracts"
+
+func init() {
+	_ = os.MkdirAll(pluginsExtractsFolder, 0755)
+}
+
+func extractPlugin(filePath string) (*string, error) {
+	file, err := os.Open(filePath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to open file for extraction: %v", err)
+	}
+	defer file.Close()
+
+	var reader io.Reader = file
+	// TODO: there's probably a better way of doing this without relying on the file extension
+	if strings.HasSuffix(filePath, ".gz") {
+		gzipReader, err := gzip.NewReader(file)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create gzip reader: %v", err)
+		}
+		defer gzipReader.Close()
+		reader = gzipReader
+	}
+
+	destinationFolder := path.Join(pluginsExtractsFolder, uuid.New().String())
+	if err := os.MkdirAll(destinationFolder, 0755); err != nil {
+		return nil, fmt.Errorf("failed to create extracts folder: %v", err)
+	}
+
+	tarReader := tar.NewReader(reader)
+
+	for {
+		header, err := tarReader.Next()
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, fmt.Errorf("failed to read tar header: %v", err)
+		}
+
+		// Prevent path traversal attacks
+		targetPath := filepath.Join(destinationFolder, header.Name)
+		if !strings.HasPrefix(targetPath, filepath.Clean(destinationFolder)+string(os.PathSeparator)) {
+			return nil, fmt.Errorf("tar file contains illegal path: %s", header.Name)
+		}
+
+		switch header.Typeflag {
+		case tar.TypeDir:
+			if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
+				return nil, fmt.Errorf("failed to create directory: %v", err)
+			}
+		case tar.TypeReg:
+			file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
+			if err != nil {
+				return nil, fmt.Errorf("failed to create file: %v", err)
+			}
+			defer file.Close()
+
+			if _, err := io.Copy(file, tarReader); err != nil {
+				return nil, fmt.Errorf("failed to extract file: %v", err)
+			}
+		default:
+			return nil, fmt.Errorf("unsupported tar entry type: %v", header.Typeflag)
+		}
+	}
+
+	return &destinationFolder, nil
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index a794bba..3a3318d 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -1,6 +1,7 @@
 package plugin
 
 import (
+	"encoding/json"
 	"fmt"
 	"kvm/internal/storage"
 	"os"
@@ -10,7 +11,7 @@ import (
 )
 
 const pluginsFolder = "/userdata/jetkvm/plugins"
-const pluginsUploadFolder = pluginsFolder + "/_uploads"
+const pluginsUploadFolder = pluginsFolder + "/uploads"
 
 func init() {
 	_ = os.MkdirAll(pluginsUploadFolder, 0755)
@@ -51,3 +52,122 @@ func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUplo
 		DataChannel:          uploadId,
 	}, nil
 }
+
+func RpcPluginExtract(filename string) (*PluginManifest, error) {
+	sanitizedFilename, err := storage.SanitizeFilename(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
+	extractFolder, err := extractPlugin(filePath)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := os.Remove(filePath); err != nil {
+		return nil, fmt.Errorf("failed to delete uploaded file: %v", err)
+	}
+
+	manifest, err := readManifest(*extractFolder)
+	if err != nil {
+		return nil, err
+	}
+
+	// Get existing PluginInstall
+	install, ok := pluginDatabase.Plugins[manifest.Name]
+	if !ok {
+		install = PluginInstall{
+			Enabled:           false,
+			Version:           manifest.Version,
+			ExtractedVersions: make(map[string]string),
+		}
+	}
+
+	_, ok = install.ExtractedVersions[manifest.Version]
+	if ok {
+		return nil, fmt.Errorf("this version has already been uploaded: %s", manifest.Version)
+	}
+
+	install.ExtractedVersions[manifest.Version] = *extractFolder
+	pluginDatabase.Plugins[manifest.Name] = install
+
+	if err := pluginDatabase.Save(); err != nil {
+		return nil, fmt.Errorf("failed to save plugin database: %v", err)
+	}
+
+	return manifest, nil
+}
+
+func RpcPluginInstall(name string, version string) error {
+	// TODO: find the plugin version in the plugins.json file
+	pluginInstall, ok := pluginDatabase.Plugins[name]
+	if !ok {
+		return fmt.Errorf("plugin not found: %s", name)
+	}
+
+	if pluginInstall.Version == version && pluginInstall.Enabled {
+		fmt.Printf("Plugin %s is already installed with version %s\n", name, version)
+		return nil
+	}
+
+	_, ok = pluginInstall.ExtractedVersions[version]
+	if !ok {
+		return fmt.Errorf("plugin version not found: %s", version)
+	}
+
+	// TODO: If there is a running plugin with the same name, stop it and start the new version
+
+	pluginInstall.Version = version
+	pluginInstall.Enabled = true
+	pluginDatabase.Plugins[name] = pluginInstall
+
+	if err := pluginDatabase.Save(); err != nil {
+		return fmt.Errorf("failed to save plugin database: %v", err)
+	}
+	// TODO: start the plugin
+
+	// TODO: Determine if the old version should be removed
+
+	return nil
+}
+
+func readManifest(extractFolder string) (*PluginManifest, error) {
+	manifestPath := path.Join(extractFolder, "manifest.json")
+	manifestFile, err := os.Open(manifestPath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to open manifest file: %v", err)
+	}
+	defer manifestFile.Close()
+
+	manifest := PluginManifest{}
+	if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil {
+		return nil, fmt.Errorf("failed to read manifest file: %v", err)
+	}
+
+	if err := validateManifest(&manifest); err != nil {
+		return nil, fmt.Errorf("invalid manifest file: %v", err)
+	}
+
+	return &manifest, nil
+}
+
+func validateManifest(manifest *PluginManifest) error {
+	if manifest.ManifestVersion != "1" {
+		return fmt.Errorf("unsupported manifest version: %s", manifest.ManifestVersion)
+	}
+
+	if manifest.Name == "" {
+		return fmt.Errorf("missing plugin name")
+	}
+
+	if manifest.Version == "" {
+		return fmt.Errorf("missing plugin version")
+	}
+
+	if manifest.Homepage == "" {
+		return fmt.Errorf("missing plugin homepage")
+	}
+
+	return nil
+}
diff --git a/internal/plugin/type.go b/internal/plugin/type.go
new file mode 100644
index 0000000..6f07c59
--- /dev/null
+++ b/internal/plugin/type.go
@@ -0,0 +1,30 @@
+package plugin
+
+import "sync"
+
+type PluginManifest struct {
+	ManifestVersion  string `json:"manifest_version"`
+	Name             string `json:"name"`
+	Version          string `json:"version"`
+	Description      string `json:"description,omitempty"`
+	Homepage         string `json:"homepage"`
+	BinaryPath       string `json:"bin"`
+	SystemMinVersion string `json:"system_min_version,omitempty"`
+}
+
+type PluginInstall struct {
+	Enabled bool `json:"enabled"`
+
+	// Current active version of the plugin
+	Version string `json:"version"`
+
+	// Map of a plugin version to the extracted directory
+	ExtractedVersions map[string]string `json:"extracted_versions"`
+}
+
+type PluginDatabase struct {
+	// Map with the plugin name as the key
+	Plugins map[string]PluginInstall `json:"plugins"`
+
+	saveMutex sync.Mutex
+}
diff --git a/jsonrpc.go b/jsonrpc.go
index 34d6ef9..6ffdd87 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -556,4 +556,6 @@ var rpcHandlers = map[string]RPCHandler{
 	"setWakeOnLanDevices":    {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
 	"resetConfig":            {Func: rpcResetConfig},
 	"pluginStartUpload":      {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
+	"pluginExtract":          {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
+	"pluginInstall":          {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}},
 }
diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx
index 5fb950f..5cbd4ad 100644
--- a/ui/src/components/UploadPluginDialog.tsx
+++ b/ui/src/components/UploadPluginDialog.tsx
@@ -5,6 +5,8 @@ import LogoBlueIcon from "@/assets/logo-blue.svg";
 import LogoWhiteIcon from "@/assets/logo-white.svg";
 import Modal from "@components/Modal";
 import {
+  PluginManifest,
+  usePluginStore,
   useRTCStore,
 } from "../hooks/stores";
 import { cx } from "../cva.config";
@@ -16,6 +18,7 @@ import { formatters } from "@/utils";
 import { PlusCircleIcon } from "@heroicons/react/20/solid";
 import AutoHeight from "./AutoHeight";
 import { useJsonRpc } from "../hooks/useJsonRpc";
+import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
 import notifications from "../notifications";
 import { isOnDevice } from "../main";
 import { ViewHeader } from "./MountMediaDialog";
@@ -35,6 +38,28 @@ export default function UploadPluginModal({
 }
 
 function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
+  const {
+    pluginUploadModalView,
+    setPluginUploadModalView,
+    pluginUploadFilename,
+    setPluginUploadFilename,
+    pluginUploadManifest,
+    setPluginUploadManifest,
+  } = usePluginStore();
+  const [send] = useJsonRpc();
+  const [extractError, setExtractError] = useState<string | null>(null);
+
+  function extractPlugin(filename: string) {
+    send("pluginExtract", { filename }, resp => {
+      if ("error" in resp) {
+        setExtractError(resp.error.data || resp.error.message);
+        return
+      }
+
+      setPluginUploadManifest(resp.result as PluginManifest);
+    });
+  }
+
   return (
     <AutoHeight>
       <div
@@ -54,14 +79,46 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
                 className="h-[24px] dark:block hidden dark:!mt-0"
               />
 
-              <UploadFileView
+              {!extractError && pluginUploadModalView === "upload" && <UploadFileView
                 onBack={() => {
                   setOpen(false)
                 }}
-                onCancelUpload={() => {
-                  setOpen(false)
+                onUploadCompleted={(filename) => {
+                  setPluginUploadFilename(filename)
+                  setPluginUploadModalView("install")
+                  extractPlugin(filename)
                 }}
-              />
+              />}
+
+              {extractError && (
+                <ErrorView
+                  errorMessage={extractError}
+                  onClose={() => {
+                    setOpen(false)
+                    setPluginUploadFilename(null)
+                    setExtractError(null)
+                  }}
+                  onRetry={() => {
+                    setExtractError(null)
+                    setPluginUploadFilename(null)
+                    setPluginUploadModalView("upload")
+                  }}
+                />
+              )}
+
+              {!extractError && pluginUploadModalView === "install" && <InstallPluginView
+                filename={pluginUploadFilename!}
+                manifest={pluginUploadManifest}
+                onInstall={() => {
+                  setOpen(false)
+                  setPluginUploadFilename(null)
+                  // TODO: Open plugin settings dialog
+                }}
+                onBack={() => {
+                  setPluginUploadModalView("upload")
+                  setPluginUploadFilename(null)
+                }}
+              />}
             </div>
           </div>
         </GridCard>
@@ -74,10 +131,10 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
 // TODO: refactor to a shared component
 function UploadFileView({
   onBack,
-  onCancelUpload,
+  onUploadCompleted,
 }: {
   onBack: () => void;
-  onCancelUpload: () => void;
+  onUploadCompleted: (filename: string) => void;
 }) {
   const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
     "idle",
@@ -177,6 +234,7 @@ function UploadFileView({
         if (offset >= file.size) {
           rtcDataChannel.close();
           setUploadState("success");
+          onUploadCompleted(file.name);
           return;
         }
 
@@ -260,6 +318,7 @@ function UploadFileView({
     xhr.onload = () => {
       if (xhr.status === 200) {
         setUploadState("success");
+        onUploadCompleted(file.name);
       } else {
         console.error("Upload error:", xhr.statusText);
         setUploadError(xhr.statusText);
@@ -459,7 +518,7 @@ function UploadFileView({
               theme="light"
               text="Cancel Upload"
               onClick={() => {
-                onCancelUpload();
+                onBack();
                 setUploadState("idle");
                 setUploadProgress(0);
                 setUploadedFileName(null);
@@ -470,7 +529,7 @@ function UploadFileView({
           ) : (
             <Button
               size="MD"
-              theme={uploadState === "success" ? "primary" : "light"}
+              theme="light"
               text="Back"
               onClick={onBack}
             />
@@ -479,4 +538,134 @@ function UploadFileView({
       </div>
     </div>
   );
+}
+
+function InstallPluginView({
+  filename,
+  manifest,
+  onInstall,
+  onBack,
+}: {
+  filename: string;
+  manifest: PluginManifest | null;
+  onInstall: () => void;
+  onBack: () => void;
+}) {
+  const [send] = useJsonRpc();
+  const [error, setError] = useState<string | null>(null);
+  const [installing, setInstalling] = useState(false);
+
+  function handleInstall() {
+    if (installing) return;
+    setInstalling(true);
+    send("pluginInstall", { name: manifest!.name, version: manifest!.version }, resp => {
+      if ("error" in resp) {
+        setError(resp.error.message);
+        return
+      }
+
+      setInstalling(false);
+      onInstall();
+    });
+  }
+
+  return (
+    <div className="w-full space-y-4">
+      <ViewHeader
+        title="Install Plugin"
+        description={
+          !manifest ?
+            `Extracting plugin from ${filename}...` :
+            `Do you want to install the plugin?`
+        }
+      />
+      {manifest && (
+        <div className="space-y-2">
+          <div className="text-sm text-slate-700 dark:text-slate-300">
+            <h3 className="text-lg font-semibold">{manifest.name}</h3>
+            <p className="text-xs">{manifest.description}</p>
+            <p className="text-xs">
+              Version: {manifest.version}
+            </p>
+            <p className="text-xs">
+              <a
+                href={manifest.homepage}
+                target="_blank"
+                rel="noreferrer"
+                className="text-blue-500 dark:text-blue-400"
+              >
+                {manifest.homepage}
+              </a>
+            </p>
+          </div>
+        </div>
+      )}
+      {error && (
+        <div
+          className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
+          style={{ animationDuration: "0.7s" }}
+        >
+          Error: {error}
+        </div>
+      )}
+      <div
+        className="space-y-2 opacity-0 animate-fadeIn"
+        style={{
+          animationDuration: "0.7s",
+        }}
+      >
+        <div className="flex justify-end w-full space-x-2">
+          <Button
+            size="MD"
+            theme="light"
+            text="Cancel"
+            onClick={() => {
+              // TODO: Delete the orphaned extraction
+              setError(null);
+              onBack();
+            }}
+          />
+          <Button
+            size="MD"
+            theme="primary"
+            text="Install"
+            onClick={handleInstall}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ErrorView({
+  errorMessage,
+  onClose,
+  onRetry,
+}: {
+  errorMessage: string | null;
+  onClose: () => void;
+  onRetry: () => void;
+}) {
+  return (
+    <div className="w-full space-y-4">
+      <div className="space-y-2">
+        <div className="flex items-center space-x-2 text-red-600">
+          <ExclamationTriangleIcon className="w-6 h-6" />
+          <h2 className="text-lg font-bold leading-tight">Plugin Extract Error</h2>
+        </div>
+        <p className="text-sm leading-snug text-slate-600">
+          An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again.
+        </p>
+      </div>
+      {errorMessage && (
+        <Card className="p-4 border border-red-200 bg-red-50">
+          <p className="text-sm font-medium text-red-800">{errorMessage}</p>
+        </Card>
+      )}
+      <div className="flex justify-end space-x-2">
+        <Button size="SM" theme="light" text="Close" onClick={onClose} />
+        <Button size="SM" theme="primary" text="Back to Upload" onClick={onRetry} />
+      </div>
+    </div>
+  );
 }
\ No newline at end of file
diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx
index bb9439a..769b943 100644
--- a/ui/src/components/sidebar/settings.tsx
+++ b/ui/src/components/sidebar/settings.tsx
@@ -253,7 +253,7 @@ export default function SettingsSidebar() {
     }
   };
 
-  const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore();
+  const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore();
 
   useEffect(() => {
     getCloudState();
@@ -776,7 +776,10 @@ export default function SettingsSidebar() {
               size="SM"
               theme="primary"
               text="Upload Plugin"
-              onClick={() => setIsPluginUploadModalOpen(true)}
+              onClick={() => {
+                setPluginUploadModalView("upload");
+                setIsPluginUploadModalOpen(true)
+              }}
             />
             <UploadPluginModal
               open={isPluginUploadModalOpen}
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts
index f29d575..9acaaf7 100644
--- a/ui/src/hooks/stores.ts
+++ b/ui/src/hooks/stores.ts
@@ -530,12 +530,37 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
 }));
 
 
+export interface PluginManifest {
+  name: string;
+  version: string;
+  description?: string;
+  homepage: string;
+}
+
 interface PluginState {
   isPluginUploadModalOpen: boolean;
   setIsPluginUploadModalOpen: (isOpen: boolean) => void;
+  
+  pluginUploadFilename: string | null;
+  setPluginUploadFilename: (filename: string | null) => void;
+
+  pluginUploadManifest: PluginManifest | null;
+  setPluginUploadManifest: (manifest: PluginManifest | null) => void;
+
+  pluginUploadModalView: "upload" | "install";
+  setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void;
 }
 
 export const usePluginStore = create<PluginState>(set => ({
   isPluginUploadModalOpen: false,
   setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }),
-}));
\ No newline at end of file
+
+  pluginUploadFilename: null,
+  setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }),
+  
+  pluginUploadManifest: null,
+  setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }),
+
+  pluginUploadModalView: "upload",
+  setPluginUploadModalView: view => set({ pluginUploadModalView: view }),
+}));