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 a1a3275618..0000000000
--- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-"use client";
-
-import Head from "next/head";
-import { api } from "@/utils/api";
-
-export function WhitelabelingProvider() {
- const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
- staleTime: 5 * 60 * 1000,
- refetchOnWindowFocus: false,
- });
-
- if (!config) return null;
-
- return (
- <>
-
- {config.metaTitle && {config.metaTitle}}
- {config.faviconUrl && }
-
-
- {config.customCss && (
-
- )}
- >
- );
-}
diff --git a/apps/dokploy/pages/_app.tsx b/apps/dokploy/pages/_app.tsx
index 1ed8380bed..6546680988 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 { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { api } from "@/utils/api";
@@ -39,9 +37,6 @@ const MyApp = ({
}
`}
-
- Dokploy
-
-
{getLayout()}
diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx
index 120bb827e1..bfe175c80f 100644
--- a/apps/dokploy/pages/_document.tsx
+++ b/apps/dokploy/pages/_document.tsx
@@ -1,10 +1,104 @@
-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;
+}
+
+// Cache the resolved favicon (inlined as a data URI) so we don't re-fetch the
+// remote image on every server render. Keyed by the configured favicon URL.
+const FAVICON_CACHE_TTL = 60 * 60 * 1000; // 1 hour
+
+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;
+ var __FAVICON_CACHE: Map;
+}
+
+const faviconCache =
+ globalThis.__FAVICON_CACHE ||
+ new Map();
+globalThis.__FAVICON_CACHE = faviconCache;
+
+/**
+ * Resolve the favicon to an inline data URI so it is present in the initial
+ * HTML and renders without a network round-trip (no flash of the default
+ * favicon). Falls back to the raw URL if the image can't be fetched.
+ */
+async function resolveFaviconHref(
+ faviconUrl: string | null | undefined,
+): Promise {
+ if (!faviconUrl) return null;
+
+ const cached = faviconCache.get(faviconUrl);
+ if (cached && cached.expiresAt > Date.now()) {
+ return cached.href;
+ }
+
+ // Default to the raw URL so the custom favicon still loads if inlining fails.
+ let href = faviconUrl;
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 3000);
+ const response = await fetch(faviconUrl, { signal: controller.signal });
+ clearTimeout(timeout);
+
+ if (response.ok) {
+ const buffer = Buffer.from(await response.arrayBuffer());
+ // Avoid embedding very large images directly in the HTML.
+ if (buffer.byteLength <= 512 * 1024) {
+ const contentType = response.headers.get("content-type") || "image/png";
+ href = `data:${contentType};base64,${buffer.toString("base64")}`;
+ }
+ }
+ } catch {
+ // Keep the raw URL fallback.
+ }
+
+ faviconCache.set(faviconUrl, {
+ href,
+ expiresAt: Date.now() + FAVICON_CACHE_TTL,
+ });
+ return href;
+}
+
+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 +107,51 @@ export default function Document() {
);
}
+
+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;
+ customCss = config.customCss;
+ faviconHref = await resolveFaviconHref(config.faviconUrl);
+ }
+ } 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/_error.tsx b/apps/dokploy/pages/_error.tsx
index 8fd5107966..23b2969e62 100644
--- a/apps/dokploy/pages/_error.tsx
+++ b/apps/dokploy/pages/_error.tsx
@@ -1,7 +1,10 @@
+import { createServerSideHelpers } from "@trpc/react-query/server";
import type { NextPageContext } from "next";
import Link from "next/link";
+import superjson from "superjson";
import { Logo } from "@/components/shared/logo";
import { buttonVariants } from "@/components/ui/button";
+import { appRouter } from "@/server/api/root";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
interface Props {
@@ -102,7 +105,30 @@ export default function Custom404({ statusCode, error }: Props) {
}
// @ts-ignore
-Error.getInitialProps = ({ res, err }: NextPageContext) => {
+Error.getInitialProps = async ({ res, err, req }: NextPageContext) => {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
+
+ // getInitialProps runs on the client too (e.g. client-side errors) where
+ // req/res are undefined. Only prefetch branding when running server-side.
+ if (req && res) {
+ try {
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ ctx: {
+ req: req as any,
+ res: res as any,
+ db: null as any,
+ session: null as any,
+ user: null as any,
+ },
+ transformer: superjson,
+ });
+ await helpers.whitelabeling.getPublic.prefetch();
+ return { statusCode, error: err, trpcState: helpers.dehydrate() };
+ } catch {
+ // If branding prefetch fails, fall through to the default return.
+ }
+ }
+
return { statusCode, error: err };
};
diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx
index 0be614d3ca..2945d9feab 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 { createServerSideHelpers } from "@trpc/react-query/server";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -12,6 +13,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
+import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
@@ -45,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";
@@ -430,6 +433,21 @@ Home.getLayout = (page: ReactElement) => {
return {page};
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ 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,
+ });
+ // 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);
@@ -445,6 +463,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
+ trpcState: helpers.dehydrate(),
IS_CLOUD: IS_CLOUD,
enforceSSO: false,
},
@@ -476,6 +495,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 1cbd773b7b..b2ec79de55 100644
--- a/apps/dokploy/pages/invitation.tsx
+++ b/apps/dokploy/pages/invitation.tsx
@@ -1,11 +1,13 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
+import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -22,6 +24,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 { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
@@ -326,6 +329,21 @@ Invitation.getLayout = (page: ReactElement) => {
return {page};
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ ctx: {
+ req: ctx.req as any,
+ res: ctx.res as any,
+ db: null as any,
+ session: null as any,
+ user: null as any,
+ },
+ transformer: superjson,
+ });
+ // 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;
@@ -354,6 +372,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (invitation.userAlreadyExists) {
return {
props: {
+ trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
@@ -373,6 +392,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 f18747773a..ad8f050643 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 { createServerSideHelpers } from "@trpc/react-query/server";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -7,6 +8,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
+import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
@@ -25,6 +27,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 registerSchema = z
@@ -297,6 +300,21 @@ Register.getLayout = (page: ReactElement) => {
return {page};
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ 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,
+ });
+ // 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);
@@ -310,6 +328,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
return {
props: {
+ trpcState: helpers.dehydrate(),
isCloud: true,
},
};
@@ -326,6 +345,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 7d3c47d518..5fbedb5ecd 100644
--- a/apps/dokploy/pages/send-reset-password.tsx
+++ b/apps/dokploy/pages/send-reset-password.tsx
@@ -1,11 +1,13 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
+import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
+import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -22,6 +24,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 +168,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 +178,24 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) {
};
}
+ const helpers = createServerSideHelpers({
+ router: appRouter,
+ 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,
+ });
+ // 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 bca5a14e14..6bc0d0f8f4 100644
--- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
+++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
@@ -14,6 +14,14 @@ 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;
+ if (globalThis.__FAVICON_CACHE) {
+ globalThis.__FAVICON_CACHE.clear();
+ }
+}
+
export const whitelabelingRouter = createTRPCRouter({
get: protectedProcedure.query(async ({ ctx }) => {
if (IS_CLOUD) {
@@ -47,6 +55,9 @@ export const whitelabelingRouter = createTRPCRouter({
whitelabelingConfig: input.whitelabelingConfig,
});
+ // Clear the cache so Next.js SSR applies changes immediately
+ clearBrandingSSRCache();
+
return { success: true };
}),
@@ -82,6 +93,9 @@ export const whitelabelingRouter = createTRPCRouter({
},
});
+ // Clear the cache so Next.js SSR applies changes immediately
+ clearBrandingSSRCache();
+
return { success: true };
}),