AI SDK v5 Migration: Handling Previously Saved Messages

Prerequisites: This article builds upon the AI SDK v5 Migration Guide. Ensure you've completed the core migration before implementing this solution.

Who should read this article?

This article is only relevant if your application saves messages and you wish to migrate to the v5 AI SDK. If you don't save messages, you can skip this article. If you wish to begin saving messages, you can follow the guide here.

The Problem

Picture this: You've just upgraded to AI SDK v5, excited about the new features. You deploy to production, and suddenly your users can't access their chat history. Their saved conversations throw errors, the UI breaks, and you're getting support tickets.

The breaking change: AI SDK v5 introduced a completely new message structure. Your existing saved messages in v4 format are incompatible with the new SDK, and there's no automatic migration path.

You don't want to lose users' valuable chat history, break existing conversations in production, force users to start over with empty chat sessions, or spend weeks manually migrating database records.

Key Changes from v4 to v5

Understanding these structural changes is crucial for building an effective transformer:

AspectV4V5Documentation
Message Structurecontent propertyparts array structureUIMessage Changes
Tool HandlingSeparate toolInvocations arrayIndividual parts in parts arrayTool Call Changes
Tool TypesGeneric tool-invocationSpecific tool-{toolName} typesTool Call Changes
State ManagementBasic states: partial-call call result errorGranular states: input-streaming input-available output-available output-errorTool Call Changes
Property Namesargs, resultinput outputTool Call Changes
ReasoningTop-level reasoning propertyreasoning parts with text propertyUIMessage Reasoning Structure
Reasoning Partspart.reasoning propertypart.text propertyUIMessage Reasoning Structure
File Partsdata mimeType propertiesurl, mediaType propertiesFile Part Changes

High-Level Migration Overview

Follow this step-by-step approach to safely migrate without breaking existing chats:

1. Upgrade to AI SDK v5
Install the new AI SDK v5 packages and update your codebase. See the migration guide for details.

Pro tip: Create a new branch, copy the official migration guide, and use AI tools like Cursor or Cody to help automate the migration. However, understanding your specific message object shape is crucial for the transformer.

2. Update Chat Components Refactor your chat and message-related components (reasoning, tool calls, file/data parts, etc.) to align with the new v5 message shape.

3. Implement the Message Transformer Call the transformer function to convert legacy messages at runtime, ensuring users can access their existing chats immediately after the upgrade.

4. Testing Validate against: historical messages, reasoning traces, tool calls, data parts, and file parts. Fix any issues that arise.

5. Future Migration Path (Optional) Once thoroughly tested, use the same transformer logic to migrate your stored messages in the database to the v5 format permanently.

Message Transformer Implementation

This transformer is designed to be simple and easy to understand, so you can modify it and use AI tools to adapt it to your specific requirements.

// You should use your type for v5 SDK https://ai-sdk.dev/docs/reference/ai-sdk-core/ui-message
interface MessagePart {
  type: string;
  [key: string]: unknown;
}

interface UIMessage {
  id: string;
  role: "user" | "assistant" | "system";
  parts: MessagePart[];
  metadata?: Record<string, unknown>;
}

// State mapping for tool calls
const STATE_MAP = {
  "partial-call": "input-streaming",
  call: "input-available",
  result: "output-available",
  error: "output-error",
} as const;

export function autoTransformMessages(messages: any[]): UIMessage[] {
  return messages.map((msg, i) => {
    // Check if message needs transformation
    const needsTransform =
      msg?.toolInvocations ||
      msg?.parts?.some((p: any) => p.type === "tool-invocation") ||
      msg?.role === "data" ||
      msg?.reasoning ||
      (msg?.content && !msg?.parts);

    if (!needsTransform) return msg;

    const parts: MessagePart[] = [];

    // Top-level reasoning β†’ reasoning part
    if (msg.reasoning) {
      parts.push({ type: "reasoning", text: msg.reasoning });
    }

    // Transform existing parts
    for (const part of msg.parts || []) {
      switch (part.type) {
        case "tool-invocation":
          const inv = part.toolInvocation;
          parts.push({
            type: `tool-${inv.toolName}`,
            toolCallId: inv.toolCallId,
            state: STATE_MAP[inv.state as keyof typeof STATE_MAP] || "output-available",
            input: inv.args,
            output: inv.result,
          });
          break;

        case "reasoning":
          parts.push({ type: "reasoning", text: part.reasoning || part.text });
          break;

        case "file":
          const url = part.url || `data:${part.mimeType || part.mediaType};base64,${part.data}`;
          parts.push({
            type: "file",
            url,
            mediaType: part.mediaType || part.mimeType,
            ...(part.name && { filename: part.name }),
          });
          break;

        default:
          parts.push(part); // Pass through unchanged
      }
    }

    // Fallback: plain content β†’ text part
    if (!parts.length && msg.content) {
      parts.push({ type: "text", text: msg.content });
    }

    // Handle legacy 'data' role
    let role = msg.role;
    if (msg.role === "data") {
      parts.push({ type: "data-legacy", payload: msg.data || msg.content });
      role = "system";
    }

    return {
      id: msg.id || `msg-${i}`,
      role,
      parts,
    };
  });
}

Usage & Implementation

Integration with useChat Hook


// Call the transformer before initializing the chat
const chat = useChat<ChatMessage>({
  id: id,
  initialMessages: autoTransformMessages(savedMessages),
});


// Or you can set messages
const transformedMessages = autoTransformMessages(chatData.messages);
setMessages(transformedMessages as UIMessage[]);

Versioning Strategy (Recommended)

Add explicit version tags to messages for cleaner detection:

// Tag new messages with version
const newMessage = {
  ...messageData,
  messageVersion: "5.0.0"
};

// In your transformer, check version first
function needsTransformation(msg: any): boolean {
  // If no version tag, assume v4
  if (!msg.messageVersion) return true;
  
  // Only transform pre-v5 messages
  return msg.messageVersion < "5.0.0";
}

This approach is more reliable than inferring versions from message shape.


Detailed Transformation Examples

These examples show how the transformer handles different message types:

1. Content β†’ Parts Array Migration (UIMessage Changes)

AI SDK 4.0:

[
  {
    "id": "1",
    "role": "user",
    "content": "Bonjour!"
  }
]

AI SDK 5.0:

[
  {
    "id": "1",
    "role": "user",
    "parts": [
      {
        "type": "text",
        "text": "Bonjour!"
      }
    ]
  }
]

2. Reasoning Structure Migration (UIMessage Reasoning Structure)

AI SDK 4.0:

[
  {
    "role": "assistant",
    "content": "Hello",
    "reasoning": "I will greet the user"
  }
]

AI SDK 5.0:

[
  {
    "role": "assistant",
    "parts": [
      {
        "type": "reasoning",
        "text": "I will greet the user"
      }
    ]
  }
]

3. Complex Tool Call with Multiple States

AI SDK 4.0:

[
  {
    "toolInvocations": [
      {
        "args": {
          "order": "asc",
          "endDate": "2024-04-30",
          "type": "EXPENSE",
          "orderBy": "timestamp",
          "startDate": "2024-04-01"
        },
        "toolCallId": "call_zmJWPPkej6kOpSxuqOZaTKTV",
        "state": "result",
        "toolName": "queryDatabaseBasedOnUserInput",
        "step": 0,
        "result": []
      }
    ],
    "parts": [
      {
        "type": "step-start"
      },
      {
        "type": "tool-invocation",
        "toolInvocation": {
          "state": "result",
          "step": 0,
          "args": {
            "order": "asc",
            "startDate": "2024-04-01",
            "orderBy": "timestamp",
            "type": "EXPENSE",
            "endDate": "2024-04-30"
          },
          "toolName": "queryDatabaseBasedOnUserInput",
          "result": {
            "totalExpenses": 2450.75,
            "categories": [
              {
                "name": "Food",
                "amount": 850.25
              },
              {
                "name": "Transport",
                "amount": 320.5
              },
              {
                "name": "Utilities",
                "amount": 1280
              }
            ],
            "month": "2024-04"
          },
          "toolCallId": "call_zmJWPPkej6kOpSxuqOZaTKTV"
        }
      },
      {
        "type": "step-start"
      },
      {
        "type": "text",
        "text": "It looks like there are no recorded expenses for April 2024 in your \"Demo\" workspace. If you need help with anything else, just let me know! 😊"
      }
    ],
    "role": "assistant",
    "id": "msg-JyOgfoGzhSRcQUikvbiD3NCQ",
    "content": "It looks like there are no recorded expenses for April 2024 in your \"Demo\" workspace. If you need help with anything else, just let me know! 😊",
    "createdAt": {
      "seconds": 1744677521,
      "nanoseconds": 435000000
    }
  }
]

AI SDK 5.0:

[
  {
    "id": "msg-JyOgfoGzhSRcQUikvbiD3NCQ",
    "role": "assistant",
    "parts": [
      {
        "type": "step-start"
      },
      {
        "type": "tool-queryDatabaseBasedOnUserInput",
        "toolCallId": "call_zmJWPPkej6kOpSxuqOZaTKTV",
        "state": "output-available",
        "input": {
          "order": "asc",
          "startDate": "2024-04-01",
          "orderBy": "timestamp",
          "type": "EXPENSE",
          "endDate": "2024-04-30"
        },
        "output": {
          "totalExpenses": 2450.75,
          "categories": [
            {
              "name": "Food",
              "amount": 850.25
            },
            {
              "name": "Transport",
              "amount": 320.5
            },
            {
              "name": "Utilities",
              "amount": 1280
            }
          ],
          "month": "2024-04"
        }
      },
      {
        "type": "step-start"
      },
      {
        "type": "text",
        "text": "It looks like there are no recorded expenses for April 2024 in your \"Demo\" workspace. If you need help with anything else, just let me know! 😊"
      },
      {
        "type": "tool-queryDatabaseBasedOnUserInput",
        "toolCallId": "call_zmJWPPkej6kOpSxuqOZaTKTV",
        "state": "output-available",
        "input": {
          "order": "asc",
          "endDate": "2024-04-30",
          "type": "EXPENSE",
          "orderBy": "timestamp",
          "startDate": "2024-04-01"
        },
        "output": []
      }
    ]
  }
]

Future Migration Path

Once your transformer is battle-tested in production, you can optionally use it to permanently migrate your database:

⚠️ Safety First: Always test in a sandbox database first. Migrate in small batches at a comfortable pace to avoid creating additional problems.

// Example database migration script
async function migrateDatabaseMessages() {
  const allChats = await db.chats.findAll();
  
  for (const chat of allChats) {
    const transformedMessages = autoTransformMessages(chat.messages);
    
    await db.chats.update(chat.id, {
      messages: transformedMessages,
      messageVersion: "5.0.0"
    });
  }
}

Conclusion

Thanks for reading, I enjoyed writing this article and I hope it helps you on your journey. If you have any questions, please feel free to reach out to me on Twitter (X) πŸ™„.

I plan to keep this article updated and update the transformer as needed.

Huge thanks to the AI SDK team, and all contributors! We truly have an amazing community and I'm happy to be a part of it.

Stay Shipping like Next Day Delivery πŸš€