diff --git a/src/app/(main)/boards/BoardCloneButton.tsx b/src/app/(main)/boards/BoardCloneButton.tsx
new file mode 100644
index 000000000..3f6c7732b
--- /dev/null
+++ b/src/app/(main)/boards/BoardCloneButton.tsx
@@ -0,0 +1,32 @@
+import { Copy } from '@/components/icons';
+import { useNavigation } from '@/components/hooks';
+import { DialogButton } from '@/components/input/DialogButton';
+import type { Board } from '@/lib/types';
+import { BoardCloneForm } from './BoardCloneForm';
+
+export function BoardCloneButton({
+ boardId,
+ showLabel = false,
+}: {
+ boardId: string;
+ showLabel?: boolean;
+}) {
+ const { router, renderUrl } = useNavigation();
+
+ const handleSave = (board: Board) => {
+ router.push(renderUrl(`/boards/${board.id}/design`, false));
+ };
+
+ return (
+ }
+ label={showLabel ? 'Clone' : undefined}
+ title="Clone board"
+ aria-label="Clone"
+ variant={showLabel ? undefined : 'quiet'}
+ width="600px"
+ >
+ {({ close }) => }
+
+ );
+}
diff --git a/src/app/(main)/boards/BoardCloneForm.tsx b/src/app/(main)/boards/BoardCloneForm.tsx
new file mode 100644
index 000000000..ec35f90cb
--- /dev/null
+++ b/src/app/(main)/boards/BoardCloneForm.tsx
@@ -0,0 +1,164 @@
+import {
+ Box,
+ Button,
+ Form,
+ FormField,
+ FormSubmitButton,
+ Loading,
+ Row,
+ Text,
+ TextField,
+} from '@umami/react-zen';
+import { useBoardQuery, useMessages, useUpdateQuery } from '@/components/hooks';
+import { LinkSelect } from '@/components/input/LinkSelect';
+import { PixelSelect } from '@/components/input/PixelSelect';
+import { WebsiteSelect } from '@/components/input/WebsiteSelect';
+import {
+ BOARD_TYPES,
+ type BoardType,
+ getBoardEntity,
+ getBoardType,
+ requiresBoardEntity,
+ setBoardEntity,
+} from '@/lib/boards';
+import type { Board } from '@/lib/types';
+
+interface BoardCloneFormValues {
+ name: string;
+ description: string;
+ type: BoardType;
+ entityId: string;
+}
+
+function getDefaultValues(board?: Partial): BoardCloneFormValues {
+ const boardType = getBoardType(board, { coerceDashboard: true });
+ const { entityId } = getBoardEntity(board);
+
+ return {
+ name: board?.name ? `${board.name} copy` : '',
+ description: board?.description ?? '',
+ type: boardType,
+ entityId: entityId ?? '',
+ };
+}
+
+export function BoardCloneForm({
+ boardId,
+ onSave,
+ onClose,
+}: {
+ boardId: string;
+ onSave?: (board: Board) => void | Promise;
+ onClose?: () => void;
+}) {
+ const { t, labels, messages, getErrorMessage } = useMessages();
+ const { data: board, isLoading } = useBoardQuery(boardId);
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/boards/${boardId}/clone`);
+ const values = getDefaultValues(board);
+ const boardType = getBoardType(board, { coerceDashboard: true });
+ const resolvedTeamId = board?.teamId;
+
+ const handleSubmit = async (data: BoardCloneFormValues) => {
+ const result = await mutateAsync({
+ name: data.name,
+ description: data.description,
+ parameters: setBoardEntity(board?.parameters, boardType, data.entityId || undefined),
+ });
+
+ toast(t(messages.saved));
+ touch('boards');
+ touch(`board:${result.id}`);
+ await onSave?.(result);
+ onClose?.();
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx b/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx
index 7c07b7d64..2bd5c7756 100644
--- a/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx
+++ b/src/app/(main)/boards/[boardId]/BoardViewHeader.tsx
@@ -5,6 +5,7 @@ import { PageHeader } from '@/components/common/PageHeader';
import { useBoard, useMessages, useNavigation } from '@/components/hooks';
import { Edit, LayoutDashboard } from '@/components/icons';
import { getBoardEntity } from '@/lib/boards';
+import { BoardCloneButton } from '../BoardCloneButton';
import { BoardEntityBadge } from '../BoardEntityBadge';
import { useBoardEntityBadgeProps } from '../useBoardEntityBadgeProps';
@@ -33,6 +34,7 @@ export function BoardViewHeader({
}>{t(labels.edit)}
+
>
)}
diff --git a/src/app/api/boards/[boardId]/clone/route.ts b/src/app/api/boards/[boardId]/clone/route.ts
new file mode 100644
index 000000000..7431cdaa6
--- /dev/null
+++ b/src/app/api/boards/[boardId]/clone/route.ts
@@ -0,0 +1,71 @@
+import type { Prisma } from '@/generated/prisma/client';
+import { z } from 'zod';
+import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
+import type { BoardParameters } from '@/lib/types';
+import { uuid } from '@/lib/crypto';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import {
+ canCreateTeamWebsite,
+ canCreateWebsite,
+ canUpdateBoard,
+ canViewBoardEntities,
+} from '@/permissions';
+import { createBoard, getBoard } from '@/queries/prisma';
+
+export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
+ const schema = z.object({
+ name: z.string().max(200).optional(),
+ description: z.string().max(500).optional(),
+ parameters: z.object({}).passthrough().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { boardId } = await params;
+
+ if (!(await canUpdateBoard(auth, boardId))) {
+ return unauthorized();
+ }
+
+ const sourceBoard = await getBoard(boardId);
+
+ if (!sourceBoard) {
+ return unauthorized();
+ }
+
+ const sourceType = normalizeBoardType(sourceBoard.type);
+
+ if (!sourceType || sourceType === BOARD_TYPES.dashboard) {
+ return badRequest({ message: 'Board cannot be cloned.' });
+ }
+
+ if (
+ (sourceBoard.teamId && !(await canCreateTeamWebsite(auth, sourceBoard.teamId))) ||
+ !(await canCreateWebsite(auth))
+ ) {
+ return unauthorized();
+ }
+
+ const parameters = (body.parameters ?? sourceBoard.parameters ?? {}) as BoardParameters;
+
+ if (!(await canViewBoardEntities(auth, sourceType, parameters))) {
+ return badRequest({ message: 'Board contains inaccessible entities.' });
+ }
+
+ const result = await createBoard({
+ id: uuid(),
+ type: sourceType,
+ name: body.name ?? sourceBoard.name,
+ description: body.description ?? sourceBoard.description,
+ parameters: parameters as Prisma.InputJsonValue,
+ teamId: sourceBoard.teamId,
+ userId: sourceBoard.teamId ? undefined : sourceBoard.userId ?? auth.user.id,
+ });
+
+ return json(result);
+}