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:
- Thinking Ad Minimum Duration - Ensures ads remain visible for a minimum time even when AI responses are fast
- Inline Banner Ad Placement - Shows banner ads inline with chat messages after assistant responses
- 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:
-
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.
-
Component Mount State: An
isMountedReftracks whether the component is still mounted. Timeout callbacks check this before updating state. -
Timeout Cleanup: All timeouts are cleared on status changes and component unmount to prevent stale callbacks.
-
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
-
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)
-
Message Count Dependency: Use
assistantMessageCountas a direct dependency. When it increases, refetch the ad for fresh contextual content. -
Initial Fetch: Always fetch on mount to show an ad immediately when the component first renders.
-
Manual Control: Use
autoFetch: falseto 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
Banner Ad Refetch Trigger
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
| Parameter | Type | Default | Recommended Range | Description |
|---|---|---|---|---|
MIN_THINKING_AD_DURATION | number (ms) | 2500 | 1500-5000 | Minimum time thinking ad must be visible before showing streaming response |
assistantMessageCount dependency | number | - | - | Count of assistant messages, used to trigger banner ad refetch |
autoFetch (thinking ad) | boolean | false | - | Set to false for manual control with status/submissionId tracking |
autoFetch (banner ad) | boolean | false | - | 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.
Banner Ad Auto-Refresh
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 ofrefetch()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
- Always implement minimum duration for thinking ads to ensure visibility
- Use submission ID tracking to prevent race conditions
- Clean up all timeouts on component unmount
- Use stable component keys to prevent unnecessary remounts
- Refetch banner ads when assistant message count increases for fresh contextual content
- Show banner ads only when not in submitted/streaming state to avoid overlap with thinking ads
- Test with fast AI responses to ensure minimum duration works correctly
- Monitor ad visibility metrics to optimize duration settings
Troubleshooting
Thinking ad not showing for subsequent messages
- Check that
submissionIdis being incremented and passed toThinkingAdComponent - Verify that
hasRefetchedForCurrentSubmissionis being reset when status changes - Ensure
conversationIdis available before callingrefetch()
Thinking ad disappears too quickly
- Increase
MIN_THINKING_AD_DURATIONvalue - Verify that
canShowStreamingResponsestate is working correctly - Check that timeout is not being cleared prematurely
Banner ad not appearing inline
- Verify
assistantMessageCountis being calculated correctly - Check that status condition (
status !== "submitted" && status !== "streaming") is correct - Ensure
refetch()is being called whenassistantMessageCountincreases
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
- Thinking Ads Component - Basic usage
- Display Ads Component - Basic usage
- Phase 2 Guide - Complete setup guide
- Troubleshooting - Common issues