diff --git a/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx
new file mode 100644
index 0000000..702636e
--- /dev/null
+++ b/apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx
@@ -0,0 +1,98 @@
+'use client';
+
+import { loadModuleComponent, MODULE_REGISTRY } from '@/lib/module-loader';
+import { useTranslations } from 'next-intl';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+
+/**
+ * Expanded module view — renders the full module UI via lazy loading.
+ *
+ * Per D-05b: expanded/full view when user opens a specific module.
+ * Uses loadModuleComponent to get the dynamically imported component.
+ *
+ * Security (T-03-09): Only slugs whitelisted in MODULE_REGISTRY are loaded.
+ * Arbitrary slugs from URL params result in a not-found state — no
+ * arbitrary imports are ever triggered.
+ */
+export default function ExpandedModulePage() {
+ const params = useParams<{ category: string; moduleSlug: string }>();
+ const { category, moduleSlug } = params;
+ const t = useTranslations('modules');
+
+ // Only load from the whitelist registry (T-03-09)
+ const isRegistered = moduleSlug in MODULE_REGISTRY;
+ const ModuleComponent = isRegistered
+ ? loadModuleComponent(moduleSlug)
+ : null;
+
+ // Not-found state for unknown/unregistered slugs
+ if (!ModuleComponent) {
+ return (
+
+
+
+
{t('notFound')}
+
+ {t('notFoundDescription')}
+
+
+ {t('backToCategory')}
+
+
+
+ );
+ }
+
+ // Render the lazily loaded module component
+ return (
+
+ {/* Back navigation */}
+
+
+
+ {t('backToCategory')}
+
+
+
+ {/* Module content — loaded on demand (MOD-04) */}
+
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
new file mode 100644
index 0000000..1c98117
--- /dev/null
+++ b/apps/web/src/lib/api.ts
@@ -0,0 +1,67 @@
+/**
+ * API client utilities for the Tessera frontend.
+ *
+ * Provides typed fetch wrappers for common API endpoints.
+ * All functions accept a cookie string for server-side auth forwarding.
+ */
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
+
+/**
+ * Module data shape from the API.
+ */
+export interface ApiModule {
+ id: string;
+ name: string;
+ slug: string;
+ category: string;
+ description: Record | string;
+ icon?: string;
+ version: string;
+ isSystem: boolean;
+}
+
+/**
+ * Fetch all active modules for the current tenant.
+ *
+ * @param cookie - Cookie header value for auth forwarding
+ * @returns Array of active modules
+ */
+export async function getActiveModules(cookie: string): Promise {
+ const response = await fetch(`${API_URL}/modules/active`, {
+ headers: { Cookie: cookie },
+ cache: 'no-store',
+ });
+
+ if (!response.ok) {
+ return [];
+ }
+
+ return response.json();
+}
+
+/**
+ * Find a specific module by its slug.
+ *
+ * Fetches all modules and filters by slug.
+ *
+ * @param slug - Module slug (e.g., 'domaincheck')
+ * @param cookie - Cookie header value for auth forwarding
+ * @returns The module if found, or null
+ */
+export async function getModuleBySlug(
+ slug: string,
+ cookie: string,
+): Promise {
+ const response = await fetch(`${API_URL}/modules`, {
+ headers: { Cookie: cookie },
+ cache: 'no-store',
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const modules: ApiModule[] = await response.json();
+ return modules.find((m) => m.slug === slug) ?? null;
+}