diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx
deleted file mode 100644
index 05e0b517a..000000000
--- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-"use client";
-
-import Head from "next/head";
-import { useTheme } from "next-themes";
-import { api } from "@/utils/api";
-
-export function WhitelabelingProvider() {
- const { resolvedTheme } = useTheme();
- const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
- staleTime: 5 * 60 * 1000,
- refetchOnWindowFocus: false,
- });
-
- const faviconHref =
- config?.faviconUrl ??
- (resolvedTheme === "dark"
- ? "/icon-dark.svg"
- : resolvedTheme === "light"
- ? "/icon-light.svg"
- : "/icon.svg");
-
- return (
- <>
-
- {config?.metaTitle && {config.metaTitle}}
-
-
-
- {config?.customCss && (
-
- )}
- >
- );
-}
diff --git a/apps/dokploy/pages/_app.tsx b/apps/dokploy/pages/_app.tsx
index 0d8397cd8..1451162d5 100644
--- a/apps/dokploy/pages/_app.tsx
+++ b/apps/dokploy/pages/_app.tsx
@@ -3,12 +3,10 @@ import "@/styles/globals.css";
import type { NextPage } from "next";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
-import Head from "next/head";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
-import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
import { Analytics } from "@/components/shared/analytics";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -40,9 +38,6 @@ const MyApp = ({
}
`}
-
- Dokploy
-
-
diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx
index 120bb827e..88e2af9a4 100644
--- a/apps/dokploy/pages/_document.tsx
+++ b/apps/dokploy/pages/_document.tsx
@@ -1,10 +1,39 @@
-import { Head, Html, Main, NextScript } from "next/document";
+import { getPublicWhitelabelingConfig } from "@dokploy/server";
+import NextDocument, {
+ type DocumentContext,
+ type DocumentInitialProps,
+ Head,
+ Html,
+ Main,
+ NextScript,
+} from "next/document";
-export default function Document() {
+interface WhitelabelingDocumentProps {
+ metaTitle: string | null;
+ faviconHref: string | null;
+ customCss: string | null;
+}
+
+export default function Document({
+ metaTitle,
+ faviconHref,
+ customCss,
+}: WhitelabelingDocumentProps) {
+ const title = metaTitle || "Dokploy";
return (
-
+ {/* Rendered on the server so the correct branding is present on first
+ paint (and for social scrapers), avoiding a flash of / fallback to
+ the default Dokploy branding. */}
+ {title}
+
+ {customCss && (
+
+ )}
@@ -13,3 +42,67 @@ export default function Document() {
);
}
+
+const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
+
+declare global {
+ var __SETTINGS_CACHE: {
+ data: {
+ metaTitle: string | null;
+ faviconHref: string | null;
+ customCss: string | null;
+ };
+ expiresAt: number;
+ } | null;
+}
+
+Document.getInitialProps = async (
+ ctx: DocumentContext,
+): Promise => {
+ const initialProps = await NextDocument.getInitialProps(ctx);
+
+ let metaTitle: string | null = null;
+ let faviconHref: string | null = null;
+ let customCss: string | null = null;
+
+ if (
+ globalThis.__SETTINGS_CACHE &&
+ globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
+ globalThis.__SETTINGS_CACHE.data
+ ) {
+ return {
+ ...initialProps,
+ ...globalThis.__SETTINGS_CACHE.data,
+ };
+ }
+
+ try {
+ const config = await getPublicWhitelabelingConfig();
+ if (config) {
+ metaTitle = config.metaTitle;
+ // Remove any tags to prevent XSS breakout
+ customCss = config.customCss
+ ? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
+ : null;
+ faviconHref = config.faviconUrl || null;
+ }
+ } catch {
+ // Fall back to defaults if settings can't be read (e.g. DB not ready)
+ }
+
+ globalThis.__SETTINGS_CACHE = {
+ data: {
+ metaTitle,
+ faviconHref,
+ customCss,
+ },
+ expiresAt: Date.now() + SETTINGS_CACHE_TTL,
+ };
+
+ return {
+ ...initialProps,
+ metaTitle,
+ faviconHref,
+ customCss,
+ };
+};
diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx
index 8f1adae72..3e41abae3 100644
--- a/apps/dokploy/pages/index.tsx
+++ b/apps/dokploy/pages/index.tsx
@@ -5,6 +5,7 @@ import {
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@@ -46,6 +47,7 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
+import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
return;
}
- // @ts-ignore
- if (data?.twoFactorRedirect as boolean) {
+ if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
setTwoFactorCode("");
setIsTwoFactor(true);
toast.info("Please enter your 2FA code");
@@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => {
return {page};
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const helpers = generateServerSideHelper(appRouter, context);
+ // Prefetch the public branding so the login/onboarding logo and app name
+ // render correctly on the server (no flash of default branding).
+ await helpers.whitelabeling.getPublic.prefetch();
+
if (IS_CLOUD) {
try {
const { user } = await validateRequest(context.req);
@@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
+ trpcState: helpers.dehydrate(),
IS_CLOUD: IS_CLOUD,
enforceSSO: false,
},
@@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
+ trpcState: helpers.dehydrate(),
hasAdmin,
enforceSSO: webServerSettings?.enforceSSO ?? false,
},
diff --git a/apps/dokploy/pages/invitation.tsx b/apps/dokploy/pages/invitation.tsx
index 4e78303f4..da6482219 100644
--- a/apps/dokploy/pages/invitation.tsx
+++ b/apps/dokploy/pages/invitation.tsx
@@ -1,5 +1,6 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -23,6 +24,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
+import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => {
return {page};
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
+ const helpers = generateServerSideHelper(appRouter, ctx);
+ // Prefetch the public branding so the invitation logo and app name render
+ // correctly on the server (no flash of default branding).
+ await helpers.whitelabeling.getPublic.prefetch();
+
const { query } = ctx;
const token = query.token;
@@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (invitation.userAlreadyExists) {
return {
props: {
+ trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
@@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
+ trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
diff --git a/apps/dokploy/pages/register.tsx b/apps/dokploy/pages/register.tsx
index 524db4dba..20c95bea6 100644
--- a/apps/dokploy/pages/register.tsx
+++ b/apps/dokploy/pages/register.tsx
@@ -1,5 +1,6 @@
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -27,6 +28,7 @@ import {
import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
+import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => {
);
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const helpers = generateServerSideHelper(appRouter, context);
+ // Prefetch the public branding so the onboarding logo and app name render
+ // correctly on the server (no flash of default branding).
+ await helpers.whitelabeling.getPublic.prefetch();
+
if (IS_CLOUD) {
const { user } = await validateRequest(context.req);
@@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
+ trpcState: helpers.dehydrate(),
isCloud: true,
},
};
@@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
+ trpcState: helpers.dehydrate(),
isCloud: false,
},
};
diff --git a/apps/dokploy/pages/send-reset-password.tsx b/apps/dokploy/pages/send-reset-password.tsx
index 7d3c47d51..2f41e50c9 100644
--- a/apps/dokploy/pages/send-reset-password.tsx
+++ b/apps/dokploy/pages/send-reset-password.tsx
@@ -1,5 +1,6 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -22,6 +23,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
+import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
@@ -165,7 +167,7 @@ export default function Home() {
Home.getLayout = (page: ReactElement) => {
return {page};
};
-export async function getServerSideProps(_context: GetServerSidePropsContext) {
+export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!IS_CLOUD) {
return {
redirect: {
@@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) {
};
}
+ const helpers = generateServerSideHelper(appRouter, context);
+ // Prefetch the public branding so the logo and app name render
+ // correctly on the server (no flash of default branding).
+ await helpers.whitelabeling.getPublic.prefetch();
+
return {
- props: {},
+ props: {
+ trpcState: helpers.dehydrate(),
+ },
};
}
diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
index bca5a14e1..bc38c8ff1 100644
--- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
+++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
@@ -14,6 +14,11 @@ import {
publicProcedure,
} from "../../trpc";
+/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */
+function clearBrandingSSRCache() {
+ globalThis.__SETTINGS_CACHE = null;
+}
+
export const whitelabelingRouter = createTRPCRouter({
get: protectedProcedure.query(async ({ ctx }) => {
if (IS_CLOUD) {
@@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({
whitelabelingConfig: input.whitelabelingConfig,
});
+ // Clear the cache so Next.js SSR applies changes immediately
+ clearBrandingSSRCache();
+
return { success: true };
}),
@@ -82,6 +90,9 @@ export const whitelabelingRouter = createTRPCRouter({
},
});
+ // Clear the cache so Next.js SSR applies changes immediately
+ clearBrandingSSRCache();
+
return { success: true };
}),
diff --git a/apps/dokploy/utils/create-server-helpers.ts b/apps/dokploy/utils/create-server-helpers.ts
new file mode 100644
index 000000000..429bc67cf
--- /dev/null
+++ b/apps/dokploy/utils/create-server-helpers.ts
@@ -0,0 +1,21 @@
+import { createServerSideHelpers } from "@trpc/react-query/server";
+import type { GetServerSidePropsContext } from "next";
+import superjson from "superjson";
+import type { AppRouter } from "@/server/api/root";
+
+export const generateServerSideHelper = (
+ router: AppRouter,
+ context: GetServerSidePropsContext,
+) => {
+ return createServerSideHelpers({
+ router,
+ ctx: {
+ req: context.req as any,
+ res: context.res as any,
+ db: null as any,
+ session: null as any,
+ user: null as any,
+ },
+ transformer: superjson,
+ });
+};