diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx
index 7a8f4546d4e..72b0f79774f 100644
--- a/apps/sim/app/chat/components/message/message.tsx
+++ b/apps/sim/app/chat/components/message/message.tsx
@@ -3,6 +3,7 @@
import { memo, useMemo, useState } from 'react'
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
+import { safeRenderValue } from '@/lib/core/utils/safe-render'
import {
ChatFileDownload,
ChatFileDownloadAll,
@@ -50,9 +51,14 @@ export const ClientChatMessage = memo(
return typeof message.content === 'object' && message.content !== null
}, [message.content])
- // Since tool calls are now handled via SSE events and stored in message.toolCalls,
- // we can use the content directly without parsing
- const cleanTextContent = message.content
+ // Safely convert content to a renderable string to prevent React error #31
+ // when workflow nodes return structured objects (e.g. { text, type })
+ // Use safeRenderValue for all content types — it extracts .text from
+ // structured objects like { text, type } and falls back to JSON.stringify
+ const cleanTextContent = useMemo(
+ () => safeRenderValue(message.content),
+ [message.content]
+ )
const content =
message.type === 'user' ? (
@@ -161,14 +167,14 @@ export const ClientChatMessage = memo(
)}
{/* Only render message bubble if there's actual text content (not just file count message) */}
- {message.content && !String(message.content).startsWith('Sent') && (
+ {cleanTextContent && !cleanTextContent.startsWith('Sent') && (
{isJsonObject ? (
-
{JSON.stringify(message.content, null, 2)}
+
{cleanTextContent}
) : (
-
{message.content as string}
+
{cleanTextContent}
)}
@@ -184,11 +190,9 @@ export const ClientChatMessage = memo(
{isJsonObject ? (
-
- {JSON.stringify(cleanTextContent, null, 2)}
-
+
{cleanTextContent}
) : (
-
+
)}
@@ -208,11 +212,7 @@ export const ClientChatMessage = memo(