diff --git a/.env.example b/.env.example
index 95eddc71ac..c0ecaa4d65 100644
--- a/.env.example
+++ b/.env.example
@@ -294,4 +294,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=1
-APP_ROUTER_SETTINGS_ADMIN_ENABLED=1
\ No newline at end of file
+APP_ROUTER_SETTINGS_ADMIN_ENABLED=1
+APP_ROUTER_APPS_SLUG_ENABLED=1
+APP_ROUTER_APPS_SLUG_SETUP_ENABLED=1
diff --git a/apps/web/abTest/middlewareFactory.ts b/apps/web/abTest/middlewareFactory.ts
index 704e5decfb..596a8b3035 100644
--- a/apps/web/abTest/middlewareFactory.ts
+++ b/apps/web/abTest/middlewareFactory.ts
@@ -6,6 +6,8 @@ import z from "zod";
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
+ ["/apps/:slug", Boolean(process.env.APP_ROUTER_APPS_SLUG_ENABLED)] as const,
+ ["/apps/:slug/setup", Boolean(process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED)] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,
diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx
new file mode 100644
index 0000000000..918ae3fa16
--- /dev/null
+++ b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/layout.tsx
@@ -0,0 +1,15 @@
+import { type ReactElement } from "react";
+
+import PageWrapper from "@components/PageWrapperAppDir";
+
+type EventTypesLayoutProps = {
+ children: ReactElement;
+};
+
+export default function Layout({ children }: EventTypesLayoutProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx
new file mode 100644
index 0000000000..ec1121c949
--- /dev/null
+++ b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/page.tsx
@@ -0,0 +1,126 @@
+import AppPage from "@pages/apps/[slug]/index";
+import { Prisma } from "@prisma/client";
+import { _generateMetadata } from "app/_utils";
+import fs from "fs";
+import matter from "gray-matter";
+import { notFound } from "next/navigation";
+import path from "path";
+import { z } from "zod";
+
+import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
+import { getAppAssetFullPath } from "@calcom/app-store/getAppAssetFullPath";
+import { APP_NAME, IS_PRODUCTION } from "@calcom/lib/constants";
+import prisma from "@calcom/prisma";
+
+const sourceSchema = z.object({
+ content: z.string(),
+ data: z.object({
+ description: z.string().optional(),
+ items: z
+ .array(
+ z.union([
+ z.string(),
+ z.object({
+ iframe: z.object({ src: z.string() }),
+ }),
+ ])
+ )
+ .optional(),
+ }),
+});
+
+export const generateMetadata = async ({ params }: { params: Record }) => {
+ const { data } = await getPageProps({ params });
+
+ return await _generateMetadata(
+ () => `${data.name} | ${APP_NAME}`,
+ () => data.description
+ );
+};
+
+export const generateStaticParams = async () => {
+ try {
+ const appStore = await prisma.app.findMany({ select: { slug: true } });
+ return appStore.map(({ slug }) => ({ slug }));
+ } catch (e: unknown) {
+ if (e instanceof Prisma.PrismaClientInitializationError) {
+ // Database is not available at build time, but that's ok – we fall back to resolving paths on demand
+ } else {
+ throw e;
+ }
+ }
+
+ return [];
+};
+
+const getPageProps = async ({ params }: { params: Record }) => {
+ if (typeof params?.slug !== "string") {
+ notFound();
+ }
+
+ const appMeta = await getAppWithMetadata({
+ slug: params?.slug,
+ });
+
+ const appFromDb = await prisma.app.findUnique({
+ where: { slug: params.slug.toLowerCase() },
+ });
+
+ const isAppAvailableInFileSystem = appMeta;
+ const isAppDisabled = isAppAvailableInFileSystem && (!appFromDb || !appFromDb.enabled);
+
+ if (!IS_PRODUCTION && isAppDisabled) {
+ return {
+ isAppDisabled: true as const,
+ data: {
+ ...appMeta,
+ },
+ };
+ }
+
+ if (!appFromDb || !appMeta || isAppDisabled) {
+ notFound();
+ }
+
+ const isTemplate = appMeta.isTemplate;
+ const appDirname = path.join(isTemplate ? "templates" : "", appFromDb.dirName);
+ const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/DESCRIPTION.md`);
+ const postFilePath = path.join(README_PATH);
+ let source = "";
+
+ try {
+ source = fs.readFileSync(postFilePath).toString();
+ source = source.replace(/{DESCRIPTION}/g, appMeta.description);
+ } catch (error) {
+ /* If the app doesn't have a README we fallback to the package description */
+ console.log(`No DESCRIPTION.md provided for: ${appDirname}`);
+ source = appMeta.description;
+ }
+
+ const result = matter(source);
+ const { content, data } = sourceSchema.parse({ content: result.content, data: result.data });
+ if (data.items) {
+ data.items = data.items.map((item) => {
+ if (typeof item === "string") {
+ return getAppAssetFullPath(item, {
+ dirName: appMeta.dirName,
+ isTemplate: appMeta.isTemplate,
+ });
+ }
+ return item;
+ });
+ }
+ return {
+ isAppDisabled: false as const,
+ source: { content, data },
+ data: appMeta,
+ };
+};
+
+export default async function Page({ params }: { params: Record }) {
+ const pageProps = await getPageProps({ params });
+
+ return ;
+}
+
+export const dynamic = "force-static";
diff --git a/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx
new file mode 100644
index 0000000000..ce6abc75ba
--- /dev/null
+++ b/apps/web/app/future/(individual-page-wrapper)/apps/[slug]/setup/page.tsx
@@ -0,0 +1,36 @@
+import SetupPage from "@pages/apps/[slug]/setup";
+import { _generateMetadata } from "app/_utils";
+import type { GetServerSidePropsContext } from "next";
+import { cookies, headers } from "next/headers";
+import { notFound, redirect } from "next/navigation";
+
+import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
+import { APP_NAME } from "@calcom/lib/constants";
+
+export const generateMetadata = async ({ params }: { params: Record }) => {
+ return await _generateMetadata(
+ () => `${params.slug} | ${APP_NAME}`,
+ () => ""
+ );
+};
+
+const getPageProps = async ({ params }: { params: Record }) => {
+ const req = { headers: headers(), cookies: cookies() };
+
+ const result = await getServerSideProps({ params, req } as unknown as GetServerSidePropsContext);
+
+ if (!result || "notFound" in result) {
+ notFound();
+ }
+
+ if ("redirect" in result) {
+ redirect(result.redirect.destination);
+ }
+
+ return result.props;
+};
+
+export default async function Page({ params }: { params: Record }) {
+ const pageProps = await getPageProps({ params });
+ return ;
+}
diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts
index 6640a2b634..40f1705315 100644
--- a/apps/web/middleware.ts
+++ b/apps/web/middleware.ts
@@ -103,6 +103,12 @@ export const config = {
"/future/event-types/",
"/settings/admin/:path*",
"/future/settings/admin/:path*",
+
+ "/apps/:slug/",
+ "/future/apps/:slug/",
+
+ "/apps/:slug/setup/",
+ "/future/apps/:slug/setup/",
],
};
diff --git a/apps/web/pages/apps/[slug]/index.tsx b/apps/web/pages/apps/[slug]/index.tsx
index da41a33402..27bd8dbdfb 100644
--- a/apps/web/pages/apps/[slug]/index.tsx
+++ b/apps/web/pages/apps/[slug]/index.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { Prisma } from "@prisma/client";
import fs from "fs";
import matter from "gray-matter";
diff --git a/apps/web/pages/apps/[slug]/setup.tsx b/apps/web/pages/apps/[slug]/setup.tsx
index 31d8c78a11..9dd65d9faf 100644
--- a/apps/web/pages/apps/[slug]/setup.tsx
+++ b/apps/web/pages/apps/[slug]/setup.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import type { InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
diff --git a/apps/web/playwright/ab-tests-redirect.e2e.ts b/apps/web/playwright/ab-tests-redirect.e2e.ts
new file mode 100644
index 0000000000..feb64ac1eb
--- /dev/null
+++ b/apps/web/playwright/ab-tests-redirect.e2e.ts
@@ -0,0 +1,61 @@
+import { expect } from "@playwright/test";
+
+import { test } from "./lib/fixtures";
+
+test.describe.configure({ mode: "parallel" });
+
+test.describe("apps/ A/B tests", () => {
+ test("should point to the /future/apps/[slug]", async ({ page, users, context }) => {
+ await context.addCookies([
+ {
+ name: "x-calcom-future-routes-override",
+ value: "1",
+ url: "http://localhost:3000",
+ },
+ ]);
+ const user = await users.create();
+
+ await user.apiLogin();
+
+ await page.goto("/apps/telegram");
+
+ await page.waitForLoadState();
+
+ const dataNextJsRouter = await page.evaluate(() =>
+ window.document.documentElement.getAttribute("data-nextjs-router")
+ );
+
+ expect(dataNextJsRouter).toEqual("app");
+
+ const locator = page.getByRole("heading", { name: "Telegram" });
+
+ await expect(locator).toBeVisible();
+ });
+
+ test("should point to the /future/apps/[slug]/setup", async ({ page, users, context }) => {
+ await context.addCookies([
+ {
+ name: "x-calcom-future-routes-override",
+ value: "1",
+ url: "http://localhost:3000",
+ },
+ ]);
+ const user = await users.create();
+
+ await user.apiLogin();
+
+ await page.goto("/apps/apple-calendar/setup");
+
+ await page.waitForLoadState();
+
+ const dataNextJsRouter = await page.evaluate(() =>
+ window.document.documentElement.getAttribute("data-nextjs-router")
+ );
+
+ expect(dataNextJsRouter).toEqual("app");
+
+ const locator = page.getByRole("heading", { name: "Connect to Apple Server" });
+
+ await expect(locator).toBeVisible();
+ });
+});
diff --git a/turbo.json b/turbo.json
index a91fc56c0d..100cd41c08 100644
--- a/turbo.json
+++ b/turbo.json
@@ -198,6 +198,8 @@
"ALLOWED_HOSTNAMES",
"ANALYZE",
"API_KEY_PREFIX",
+ "APP_ROUTER_APPS_SLUG_ENABLED",
+ "APP_ROUTER_APPS_SLUG_SETUP_ENABLED",
"APP_ROUTER_EVENT_TYPES_ENABLED",
"APP_ROUTER_SETTINGS_ADMIN_ENABLED",
"APP_USER_NAME",