diff --git a/web/app/loading.tsx b/web/app/loading.tsx
new file mode 100644
index 0000000..86e2072
--- /dev/null
+++ b/web/app/loading.tsx
@@ -0,0 +1,5 @@
+import TableSkeleton from "@/components/table-skeleton";
+
+export default function Loading() {
+ return ;
+}
diff --git a/web/app/users/loading.tsx b/web/app/users/loading.tsx
new file mode 100644
index 0000000..db948f6
--- /dev/null
+++ b/web/app/users/loading.tsx
@@ -0,0 +1,5 @@
+import TableSkeleton from "@/components/table-skeleton";
+
+export default function Loading() {
+ return ;
+}
diff --git a/web/components/table-skeleton.tsx b/web/components/table-skeleton.tsx
new file mode 100644
index 0000000..810916f
--- /dev/null
+++ b/web/components/table-skeleton.tsx
@@ -0,0 +1,50 @@
+type Props = { eyebrow: string; title: string; rows?: number };
+
+/**
+ * Lightweight skeleton that mimics the AccountsTable / UsersTable shell.
+ * Rendered by app/loading.tsx and app/users/loading.tsx — Next.js shows
+ * this immediately on tab navigation, then streams in the real Server
+ * Component once its data fetch resolves. Without it, the previous
+ * route's UI freezes until the fetch finishes (the "tab switch is
+ * laggy" symptom).
+ *
+ * The pulse animation comes from Tailwind's animate-pulse on each
+ * placeholder bar; no JS, no layout shift when the real content swaps in.
+ */
+export default function TableSkeleton({ eyebrow, title, rows = 8 }: Props) {
+ return (
+
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: rows }).map((_, i) => (
+ -
+
+
+ ))}
+
+
+ );
+}