diff --git a/apps/admin/index.html b/apps/admin/index.html
new file mode 100644
index 0000000..49d4ec8
--- /dev/null
+++ b/apps/admin/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ XTablo Admin
+
+
+
+
+
+
diff --git a/apps/admin/package.json b/apps/admin/package.json
new file mode 100644
index 0000000..c431f5f
--- /dev/null
+++ b/apps/admin/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@xtablo/admin",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 5176",
+ "build": "tsc -b && vite build --mode production",
+ "deploy": "wrangler deploy",
+ "typecheck": "tsc -b",
+ "lint": "biome check .",
+ "lint:fix": "biome check --write .",
+ "format": "biome format --write .",
+ "preview": "vite preview",
+ "test": "vitest run --mode test --passWithNoTests",
+ "test:watch": "vitest watch --mode test --passWithNoTests",
+ "clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.2.5",
+ "@cloudflare/vite-plugin": "^1.9.4",
+ "@tailwindcss/vite": "^4.0.14",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@types/react": "19.0.10",
+ "@types/react-dom": "19.0.4",
+ "@vitejs/plugin-react": "^4.3.4",
+ "happy-dom": "^20.0.0",
+ "tailwindcss": "^4.0.14",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.2.2",
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "^3.2.4",
+ "wrangler": "^4.24.3"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.69.0",
+ "@xtablo/shared": "workspace:*",
+ "@xtablo/shared-types": "workspace:*",
+ "@xtablo/ui": "workspace:*",
+ "react": "19.0.0",
+ "react-dom": "19.0.0",
+ "react-router-dom": "^7.9.4"
+ }
+}
diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx
new file mode 100644
index 0000000..d723e06
--- /dev/null
+++ b/apps/admin/src/App.tsx
@@ -0,0 +1,9 @@
+import AppRoutes from "./routes";
+
+export default function App() {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/main.css b/apps/admin/src/main.css
new file mode 100644
index 0000000..70631d1
--- /dev/null
+++ b/apps/admin/src/main.css
@@ -0,0 +1,30 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(0.97 0.01 95);
+ --foreground: oklch(0.2 0.02 255);
+ --card: oklch(0.995 0.002 95);
+ --card-foreground: oklch(0.2 0.02 255);
+ --border: oklch(0.88 0.01 95);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-border: var(--border);
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ body {
+ @apply bg-background text-foreground antialiased;
+ }
+}
diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx
new file mode 100644
index 0000000..6781fde
--- /dev/null
+++ b/apps/admin/src/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+
+import "@xtablo/ui/globals.css";
+import "./main.css";
+
+createRoot(document.getElementById("admin-root")!).render(
+
+
+
+
+
+);
diff --git a/apps/admin/src/routes.test.tsx b/apps/admin/src/routes.test.tsx
new file mode 100644
index 0000000..418cc42
--- /dev/null
+++ b/apps/admin/src/routes.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import AppRoutes from "./routes";
+
+it("renders the privileged gate on the root route", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/admin access token/i)).toBeInTheDocument();
+});
diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx
new file mode 100644
index 0000000..3e30c96
--- /dev/null
+++ b/apps/admin/src/routes.tsx
@@ -0,0 +1,19 @@
+import { Route, Routes } from "react-router-dom";
+
+function PrivilegedGatePlaceholder() {
+ return (
+
+
+
Admin access token required
+
+
+ );
+}
+
+export default function AppRoutes() {
+ return (
+
+ } />
+
+ );
+}
diff --git a/apps/admin/src/setupTests.ts b/apps/admin/src/setupTests.ts
new file mode 100644
index 0000000..b403818
--- /dev/null
+++ b/apps/admin/src/setupTests.ts
@@ -0,0 +1,6 @@
+import "@testing-library/jest-dom";
+import { cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json
new file mode 100644
index 0000000..586e2a9
--- /dev/null
+++ b/apps/admin/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "types": ["vite/client", "vitest/globals"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@xtablo/ui": ["../../packages/ui/src"],
+ "@xtablo/ui/*": ["../../packages/ui/src/*"],
+ "@xtablo/shared": ["../../packages/shared/src"],
+ "@xtablo/shared/*": ["../../packages/shared/src/*"],
+ "@xtablo/shared-types": ["../../packages/shared-types/src"],
+ "@xtablo/shared-types/*": ["../../packages/shared-types/src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": []
+}
diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts
new file mode 100644
index 0000000..b80495d
--- /dev/null
+++ b/apps/admin/vite.config.ts
@@ -0,0 +1,29 @@
+///
+
+import { cloudflare } from "@cloudflare/vite-plugin";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig, type PluginOption } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+export default defineConfig(({ mode }) => {
+ const plugins: PluginOption[] = [
+ react(),
+ tailwindcss(),
+ tsconfigPaths({ ignoreConfigErrors: true }),
+ ];
+
+ if (mode !== "test" && process.env.VITEST !== "true") {
+ plugins.push(cloudflare({ inspectorPort: 9233 }));
+ }
+
+ return {
+ plugins,
+ server: { cors: false },
+ test: {
+ globals: true,
+ environment: "happy-dom",
+ setupFiles: "./src/setupTests.ts",
+ },
+ };
+});
diff --git a/apps/admin/worker/index.ts b/apps/admin/worker/index.ts
new file mode 100644
index 0000000..bee9dbb
--- /dev/null
+++ b/apps/admin/worker/index.ts
@@ -0,0 +1,9 @@
+export default {
+ fetch(request: Request) {
+ const url = new URL(request.url);
+ if (url.pathname.startsWith("/api/")) {
+ return Response.json({ name: "XTablo Admin Worker" });
+ }
+ return new Response(null, { status: 404 });
+ },
+};
diff --git a/apps/admin/wrangler.toml b/apps/admin/wrangler.toml
new file mode 100644
index 0000000..8083093
--- /dev/null
+++ b/apps/admin/wrangler.toml
@@ -0,0 +1,16 @@
+name = "xtablo-admin"
+main = "worker/index.ts"
+compatibility_date = "2025-07-09"
+
+[assets]
+directory = "./dist/"
+not_found_handling = "single-page-application"
+
+[observability]
+enabled = true
+
+[env.staging]
+route = { pattern = "admin-staging.internal.xtablo.com", custom_domain = true }
+
+[env.production]
+route = { pattern = "admin.internal.xtablo.com", custom_domain = true }
diff --git a/package.json b/package.json
index bf99826..6c383b5 100644
--- a/package.json
+++ b/package.json
@@ -14,11 +14,13 @@
"build:prod": "turbo build:prod --filter=@xtablo/main",
"dev": "turbo dev",
"dev:main": "turbo dev --filter=@xtablo/main",
+ "dev:admin": "turbo dev --filter=@xtablo/admin",
"dev:external": "turbo dev --filter=@xtablo/external",
"dev:clients": "turbo dev --filter=@xtablo/clients",
"dev:api": "turbo dev --filter=@xtablo/api",
"deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main",
"deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main",
+ "deploy:admin": "turbo deploy --filter=@xtablo/admin",
"deploy:chat": "turbo deploy --filter=@xtablo/chat-worker",
"deploy:external": "turbo deploy --filter=@xtablo/external",
"lint": "turbo lint",