ImplementationAdvanced Display Ads

Advanced Display Ad Implementation

Production-ready patterns for thinking ads with minimum display duration and inline banner ad placement.

Overview

This section covers advanced implementation patterns for display ads that ensure consistent behavior, prevent race conditions, and provide a better user experience:

  1. Thinking Ad Minimum Duration - Ensures ads remain visible for a minimum time even when AI responses are fast
  2. Inline Banner Ad Placement - Shows banner ads inline with chat messages after assistant responses
  3. Configuration Options - How to customize these parameters

Thinking Ad Minimum Duration

Problem

When AI responses are very fast, thinking ads can appear and disappear almost instantly, making them barely visible to users. This reduces ad visibility and potential revenue.

Solution

Implement a minimum display duration that prevents the streaming AI response from appearing until the minimum time has elapsed. This ensures thinking ads are always visible for at least the configured duration.

Implementation Pattern

// 1. Define minimum duration constant (configurable)
const MIN_THINKING_AD_DURATION = 2500; // 2.5 seconds (2500ms)
 
// 2. In your messages component, add state and refs
function MessagesComponent({ status, messages }) {
  const [canShowStreamingResponse, setCanShowStreamingResponse] = useState(false);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const currentSubmissionIdRef = useRef<number>(0);
  const isMountedRef = useRef(true);
  
  // 3. When status becomes "submitted", start the timer
  useEffect(() => {
    if (status === "submitted") {
      // Increment submission ID to track this specific submission
      currentSubmissionIdRef.current += 1;
      const submissionId = currentSubmissionIdRef.current;
      
      // Reset state for new message
      setCanShowStreamingResponse(false);
      
      // Clear any existing timeout
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
      
      // Set timeout to allow showing streaming response after minimum duration
      // Use submission ID to prevent stale callbacks from previous submissions
      timeoutRef.current = setTimeout(() => {
        // Only update state if this is still the current submission and component is mounted
        if (isMountedRef.current && submissionId === currentSubmissionIdRef.current) {
          setCanShowStreamingResponse(true);
        }
        timeoutRef.current = null;
      }, MIN_THINKING_AD_DURATION);
    }
    
    // Cleanup timeout on unmount or status change
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    };
  }, [status]);
  
  // 4. Reset when status is no longer submitted/streaming
  useEffect(() => {
    if (status !== "submitted" && status !== "streaming") {
      // Invalidate current submission
      currentSubmissionIdRef.current += 1;
      setCanShowStreamingResponse(false);
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    }
  }, [status]);
  
  // 5. Track mount state for cleanup
  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
      // Cleanup on unmount
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    };
  }, []);
  
  // 6. Conditionally hide streaming message until minimum duration passes
  return (
    <>
      {messages.map((message, index) => {
        const isStreaming = status === "streaming" && index === messages.length - 1;
        const isLastAssistantMessage = index === messages.length - 1 && message.role === 'assistant';
        
        // Hide streaming assistant message if minimum duration hasn't passed
        const shouldHideStreamingMessage = 
          isStreaming && 
          isLastAssistantMessage && 
          !canShowStreamingResponse;
        
        return (
          <div key={message.id}>
            {!shouldHideStreamingMessage && (
              <MessageComponent message={message} isLoading={isStreaming} />
            )}
          </div>
        );
      })}
      
      {/* 7. Show thinking ad when status is submitted or streaming (before minimum duration) */}
      {(status === "submitted" || (status === "streaming" && !canShowStreamingResponse)) && (
        <ThinkingAdComponent 
          status={status}
          submissionId={currentSubmissionIdRef.current}
          key="thinking-ad-stable" // Stable key prevents unnecessary remounts
        />
      )}
    </>
  );
}

Thinking Ad Component with Manual Fetch

// ThinkingAdComponent with manual fetch control
import { useDisplayAd } from '@earnlayer/sdk/react';
import { useEarnLayerClient } from '@earnlayer/sdk/react';
import { useEffect, useRef } from 'react';
 
function ThinkingAdComponent({ status, submissionId = 0 }: { status?: string; submissionId?: number }) {
  const { conversationId } = useEarnLayerClient();
  
  // Use autoFetch: false for manual control
  const { ad, isLoading, error, refetch, refetchWithRefresh } = useDisplayAd({
    adType: 'thinking',
    autoFetch: false, // Manual control
    debug: true
  });
  
  const prevSubmissionIdRef = useRef<number>(0);
  const prevStatusRef = useRef<string | undefined>(undefined);
  const hasRefetchedForCurrentSubmission = useRef(false);
  
  // Refetch when submissionId changes (new message sent) or status becomes "submitted"
  useEffect(() => {
    const isSubmitted = status === "submitted";
    const wasSubmitted = prevStatusRef.current === "submitted";
    const isNewSubmission = submissionId !== prevSubmissionIdRef.current;
    
    // New submission detected - reset and fetch
    if (isNewSubmission && isSubmitted) {
      prevSubmissionIdRef.current = submissionId;
      hasRefetchedForCurrentSubmission.current = false;
      
      if (!conversationId) {
        console.warn('[ThinkingAd] No conversationId - cannot fetch ad');
        prevStatusRef.current = status;
        return;
      }
      
      hasRefetchedForCurrentSubmission.current = true;
      refetch().catch(err => {
        console.error('[ThinkingAd] Fetch error:', err);
        hasRefetchedForCurrentSubmission.current = false; // Allow retry
      });
      prevStatusRef.current = status;
      return;
    }
    
    // When status becomes "submitted" (transition from non-submitted to submitted)
    if (isSubmitted && !wasSubmitted && !hasRefetchedForCurrentSubmission.current) {
      if (!conversationId) {
        console.warn('[ThinkingAd] No conversationId - cannot fetch ad');
        prevStatusRef.current = status;
        return;
      }
      
      hasRefetchedForCurrentSubmission.current = true;
      refetch().catch(err => {
        console.error('[ThinkingAd] Fetch error:', err);
        hasRefetchedForCurrentSubmission.current = false; // Allow retry
      });
    }
    
    // Reset flag when status is no longer "submitted" (ready for next message)
    if (!isSubmitted && wasSubmitted) {
      hasRefetchedForCurrentSubmission.current = false;
    }
    
    prevStatusRef.current = status;
  }, [status, submissionId, refetch, conversationId]);
  
  // Show loading state while fetching or if we're in submitted/streaming state without an ad
  if (isLoading || ((status === "submitted" || status === "streaming") && !ad && !error)) {
    return (
      <div className="thinking-ad-loading">
        <Loader2 className="animate-spin" />
        <span>Loading sponsored content...</span>
      </div>
    );
  }
  
  if (error || !ad) {
    return null;
  }
  
  return (
    <div className="thinking-ad" onClick={() => window.open(ad.url, '_blank')}>
      <span className="sponsored-label">Sponsored</span>
      <h3>{ad.title}</h3>
      {ad.advertiser && <p>by {ad.advertiser}</p>}
    </div>
  );
}

Key Points

Race Condition Prevention

The implementation uses several mechanisms to prevent race conditions:

  1. Submission ID Tracking: Each new “submitted” status increments a submission ID. Timeout callbacks check if they’re still for the current submission before updating state.

  2. Component Mount State: An isMountedRef tracks whether the component is still mounted. Timeout callbacks check this before updating state.

  3. Timeout Cleanup: All timeouts are cleared on status changes and component unmount to prevent stale callbacks.

  4. Stable Component Key: The ThinkingAdComponent uses a stable key (“thinking-ad-stable”) to prevent unnecessary remounts that could lose internal state.

Inline Banner Ad Placement

Pattern

To show banner ads inline with chat messages (after assistant responses), use the assistantMessageCount dependency pattern:

Complete Example

// In your messages component
function MessagesComponent({ messages, status }) {
  return (
    <div className="messages-container">
      {messages.map((message, index) => {
        const isLastMessage = index === messages.length - 1;
        const isLastAssistantMessage = 
          isLastMessage && message.role === 'assistant';
        
        return (
          <div key={message.id}>
            <MessageComponent message={message} />
            
            {/* Banner Ad - Show after last assistant message, inline with chat */}
            {isLastAssistantMessage && 
             status !== "submitted" && 
             status !== "streaming" && (
              <div className="mt-4">
                <DisplayAdComponent
                  assistantMessageCount={
                    messages.filter(m => m.role === 'assistant').length
                  }
                  onAdShown={() => console.log('Banner ad shown')}
                />
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}
 
// DisplayAdComponent with assistantMessageCount dependency
import { useDisplayAd } from '@earnlayer/sdk/react';
import { useEffect, useRef } from 'react';
 
function DisplayAdComponent({ 
  assistantMessageCount = 0,
  className,
  onAdClick,
  onAdShown
}: {
  assistantMessageCount?: number;
  className?: string;
  onAdClick?: () => void;
  onAdShown?: () => void;
}) {
  const { ad, isLoading, error, refetch, refetchWithRefresh } = useDisplayAd({
    adType: 'banner',
    autoFetch: false // Manual control
  });
  
  const prevMessageCountRef = useRef(assistantMessageCount);
  const hasInitialFetchRef = useRef(false);
  
  // Initial fetch on mount
  useEffect(() => {
    if (!hasInitialFetchRef.current) {
      hasInitialFetchRef.current = true;
      refetch();
    }
  }, [refetch]);
  
  // Refetch whenever assistant message count increases (new AI response)
  useEffect(() => {
    if (assistantMessageCount > prevMessageCountRef.current) {
      prevMessageCountRef.current = assistantMessageCount;
      refetchWithRefresh(); // Use refresh to exclude last ad
    }
  }, [assistantMessageCount, refetchWithRefresh]);
  
  // Track ad shown
  useEffect(() => {
    if (ad && onAdShown) {
      onAdShown();
    }
  }, [ad?.id, onAdShown]);
  
  const handleClick = () => {
    if (ad?.url) {
      // SDK automatically tracks clicks via ad.url redirect
      window.open(ad.url, '_blank', 'noopener,noreferrer');
      onAdClick?.();
    }
  };
  
  if (isLoading) {
    return (
      <div className={`animate-pulse bg-gray-200 rounded-lg h-16 w-full ${className || ''}`}>
        <div className="h-full w-full bg-gray-300 rounded-lg opacity-50" />
      </div>
    );
  }
  
  if (!ad || error) {
    return null;
  }
  
  return (
    <div
      className={`bg-gradient-to-r from-purple-100 to-blue-100 border border-purple-200 rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow ${className || ''}`}
      onClick={handleClick}
    >
      <div className="flex justify-between items-center">
        <div className="flex-1">
          <h3 className="font-semibold text-gray-900 text-sm mb-1">{ad.title}</h3>
          {ad.description && (
            <p className="text-gray-700 text-xs line-clamp-2">{ad.description}</p>
          )}
        </div>
        <div className="ml-4 flex items-center space-x-2">
          <span className="text-xs text-gray-500 bg-white/50 px-2 py-1 rounded">
            Sponsored
          </span>
          <button className="text-purple-600 hover:text-purple-800 font-medium text-sm">

          </button>
        </div>
      </div>
    </div>
  );
}

Key Points

  1. Display Condition: Show banner ad only when:

    • It’s the last assistant message
    • Status is not “submitted” or “streaming” (to avoid showing during thinking state)
  2. Message Count Dependency: Use assistantMessageCount as a direct dependency. When it increases, refetch the ad for fresh contextual content.

  3. Initial Fetch: Always fetch on mount to show an ad immediately when the component first renders.

  4. Manual Control: Use autoFetch: false to have full control over when ads are fetched.

Configuration Options

All parameters can be customized to fit your application’s needs:

MIN_THINKING_AD_DURATION

Default: 2500 (2.5 seconds)

Recommended Range: 1500-5000ms

How to Change:

// In your messages component file
const MIN_THINKING_AD_DURATION = 3000; // 3 seconds
 
// Or make it configurable via props/environment
const MIN_THINKING_AD_DURATION = 
  process.env.NEXT_PUBLIC_THINKING_AD_DURATION 
    ? parseInt(process.env.NEXT_PUBLIC_THINKING_AD_DURATION) 
    : 2500;

Considerations:

  • Too short (< 1.5s): Ads may still disappear too quickly
  • Too long (> 5s): Users may feel the AI is slow to respond
  • Recommended: 2-3 seconds provides good balance

Default: Refetch when assistantMessageCount increases

Customization Options:

// Option 1: Refetch on any message (user or assistant)
const totalMessageCount = messages.length;
useEffect(() => {
  if (totalMessageCount > prevMessageCountRef.current) {
    prevMessageCountRef.current = totalMessageCount;
    refetchWithRefresh(); // Use refresh to exclude last ad
  }
}, [totalMessageCount, refetchWithRefresh]);
 
// Option 2: Refetch with delay after message
useEffect(() => {
  if (assistantMessageCount > prevMessageCountRef.current) {
    prevMessageCountRef.current = assistantMessageCount;
    // Wait 500ms before refetching
    const timer = setTimeout(() => {
      refetchWithRefresh(); // Use refresh to exclude last ad
    }, 500);
    return () => clearTimeout(timer);
  }
}, [assistantMessageCount, refetchWithRefresh]);
 
// Option 3: Refetch based on conversation topic change
// (requires topic detection logic)
useEffect(() => {
  if (conversationTopicChanged) {
    refetchWithRefresh(); // Use refresh to exclude last ad
  }
}, [conversationTopicChanged, refetchWithRefresh]);

Display Conditions

Default: Show after last assistant message when not submitted/streaming

Customization Options:

// Option 1: Show after every assistant message (not just last)
{message.role === 'assistant' && status !== "submitted" && status !== "streaming" && (
  <DisplayAdComponent assistantMessageCount={assistantMessageCount} />
)}
 
// Option 2: Show only after every Nth assistant message
{isLastAssistantMessage && 
 assistantMessageCount % 3 === 0 && // Every 3rd assistant message
 status !== "submitted" && 
 status !== "streaming" && (
  <DisplayAdComponent assistantMessageCount={assistantMessageCount} />
)}
 
// Option 3: Show based on message length
{isLastAssistantMessage && 
 message.text.length > 500 && // Only for longer responses
 status !== "submitted" && 
 status !== "streaming" && (
  <DisplayAdComponent assistantMessageCount={assistantMessageCount} />
)}

Configuration Reference Table

ParameterTypeDefaultRecommended RangeDescription
MIN_THINKING_AD_DURATIONnumber (ms)25001500-5000Minimum time thinking ad must be visible before showing streaming response
assistantMessageCount dependencynumber--Count of assistant messages, used to trigger banner ad refetch
autoFetch (thinking ad)booleanfalse-Set to false for manual control with status/submissionId tracking
autoFetch (banner ad)booleanfalse-Set to false for manual control with assistantMessageCount dependency
status conditions (banner)array['!submitted', '!streaming']-When to show banner ad (not during thinking state)

Auto-Refresh Display Ads

Display ads can automatically refresh to show different ads from the queue. This is useful for keeping ads fresh and showing variety.

function AutoRefreshBannerAd({ assistantMessageCount }: { assistantMessageCount: number }) {
  const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
    adType: 'banner',
    autoFetch: false
  });
 
  const [refreshCount, setRefreshCount] = useState(0);
  const MAX_REFRESHES = 3; // Show 4 total ads: initial + 3 refreshes
  const prevMessageCountRef = useRef(assistantMessageCount);
 
  // Initial fetch on mount
  useEffect(() => {
    refetchWithRefresh();
  }, [refetchWithRefresh]);
 
  // Refetch when assistant message count increases
  useEffect(() => {
    if (assistantMessageCount > prevMessageCountRef.current) {
      prevMessageCountRef.current = assistantMessageCount;
      refetchWithRefresh();
      setRefreshCount(0); // Reset refresh count for new message
    }
  }, [assistantMessageCount, refetchWithRefresh]);
 
  // Auto-refresh every 5 seconds
  useEffect(() => {
    if (!ad || isLoading || refreshCount >= MAX_REFRESHES) return;
 
    const timer = setTimeout(() => {
      refetchWithRefresh();
      setRefreshCount(prev => prev + 1);
    }, 5000); // 5 seconds
 
    return () => clearTimeout(timer);
  }, [ad?.id, isLoading, refreshCount, refetchWithRefresh]);
 
  if (isLoading || !ad) return null;
 
  return (
    <a href={ad.url} target="_blank" rel="noopener noreferrer">
      {/* Ad content */}
    </a>
  );
}

Thinking Ad Auto-Refresh

function AutoRefreshThinkingAd({ status }: { status?: string }) {
  const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
    adType: 'thinking',
    autoFetch: true,
    thinkingAdTimeout: 4
  });
 
  // Auto-refresh every 5 seconds while ad is visible
  useEffect(() => {
    if (!ad || isLoading || status !== "submitted") return;
 
    const timer = setTimeout(() => {
      refetchWithRefresh();
    }, 5000); // 5 seconds
 
    return () => clearTimeout(timer);
  }, [ad?.id, isLoading, status, refetchWithRefresh]);
 
  if (isLoading || !ad) return null;
 
  return (
    <a href={ad.url} target="_blank" rel="noopener noreferrer">
      {/* Ad content */}
    </a>
  );
}

Best Practices:

  • Use refetchWithRefresh() instead of refetch() to avoid showing the same ad twice
  • Limit refreshes to prevent overwhelming users (recommended: 3-5 refreshes)
  • Reset refresh count when manually fetching new ads (e.g., after new message)
  • Timer automatically resets when ad changes (new ad loaded)

Best Practices

  1. Always implement minimum duration for thinking ads to ensure visibility
  2. Use submission ID tracking to prevent race conditions
  3. Clean up all timeouts on component unmount
  4. Use stable component keys to prevent unnecessary remounts
  5. Refetch banner ads when assistant message count increases for fresh contextual content
  6. Show banner ads only when not in submitted/streaming state to avoid overlap with thinking ads
  7. Test with fast AI responses to ensure minimum duration works correctly
  8. Monitor ad visibility metrics to optimize duration settings

Troubleshooting

Thinking ad not showing for subsequent messages

  • Check that submissionId is being incremented and passed to ThinkingAdComponent
  • Verify that hasRefetchedForCurrentSubmission is being reset when status changes
  • Ensure conversationId is available before calling refetch()

Thinking ad disappears too quickly

  • Increase MIN_THINKING_AD_DURATION value
  • Verify that canShowStreamingResponse state is working correctly
  • Check that timeout is not being cleared prematurely
  • Verify assistantMessageCount is being calculated correctly
  • Check that status condition (status !== "submitted" && status !== "streaming") is correct
  • Ensure refetch() is being called when assistantMessageCount increases

Race conditions causing state issues

  • Verify all timeouts are cleared in cleanup functions
  • Check that submission ID tracking is working correctly
  • Ensure component mount state is checked before state updates

Next Steps