YouTube API Configuration
Get YouTube API Key
Go to Google Cloud Console
Create a new project
Enable YouTube Data API v3
Create credentials → API Key
Copy your API key
Create Cloudflare Account
Go to Cloudflare
Create a free account
Go to Workers & Pages
Click Create application
Deploy Worker
Add API Key Secret
Open your Worker in Cloudflare
Go to Settings → Variables and Secrets
Click Add → Secret
Name the secret: YOUTUBE_API_KEY
Paste your YouTube API key as the value
Save and redeploy the Worker
Create a worker and put this code inside:
function decodeHTMLEntities(str) {
if (typeof str !== 'string') return str
return str
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
}
function decodeSnippetFields(data) {
if (!data || !Array.isArray(data.items)) return data
data.items = data.items.map(item => {
if (!item.snippet) return item
const s = item.snippet
if (s.title) s.title = decodeHTMLEntities(s.title)
if (s.description) s.description = decodeHTMLEntities(s.description)
if (s.channelTitle) s.channelTitle = decodeHTMLEntities(s.channelTitle)
return item
})
return data
}
export default {
async fetch(request, env) {
// Gérer les CORS
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
})
}
// Seulement GET autorisé
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
})
}
try {
const url = new URL(request.url)
const channelId = url.searchParams.get('channelId')
let maxResults = url.searchParams.get('maxResults') || '10'
const allowShorts = url.searchParams.get('allowShorts') || 'false'
// Validation channelId
if (!channelId) {
return new Response(JSON.stringify({ error: 'channelId parameter is required' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
})
}
// Validation format channelId (alphanumérique, tirets, underscores uniquement)
if (!/^[a-zA-Z0-9_-]+$/.test(channelId)) {
return new Response(JSON.stringify({ error: 'Invalid channelId format' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
})
}
// Validation et limitation maxResults
maxResults = parseInt(maxResults, 10)
if (isNaN(maxResults) || maxResults < 1) {
maxResults = 10
}
// Limiter à 50 max (limite API YouTube)
if (maxResults > 50) {
maxResults = 50
}
maxResults = String(maxResults)
// Validation allowShorts
const safeAllowShorts = allowShorts === 'true'
// Récupérer la clé API depuis les secrets Cloudflare
const apiKey = env.YOUTUBE_API_KEY
if (!apiKey) {
return new Response(JSON.stringify({ error: 'API key not configured' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
})
}
// Cache Cloudflare 24h : retourner la réponse en cache si présente
const cachedResponse = await caches.default.match(request)
if (cachedResponse) {
return cachedResponse
}
// Encoder les paramètres pour éviter l'injection
const encodedChannelId = encodeURIComponent(channelId)
const encodedMaxResults = encodeURIComponent(maxResults)
// Timeout de 10 secondes pour les requêtes API
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
try {
if (safeAllowShorts) {
const apiUrl = `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodedChannelId}&maxResults=${encodedMaxResults}&order=date&type=video&videoDuration=short&key=${apiKey}`
const apiResponse = await fetch(apiUrl, { signal: controller.signal })
clearTimeout(timeoutId)
if (!apiResponse.ok) {
throw new Error(`YouTube API error: ${apiResponse.status}`)
}
const data = await apiResponse.json()
if (!data || typeof data !== 'object' || !Array.isArray(data.items)) {
throw new Error('Invalid response format from YouTube API')
}
decodeSnippetFields(data)
const response = new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=86400'
}
})
await caches.default.put(request, response.clone())
return response
} else {
const [mediumResponse, longResponse] = await Promise.all([
fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodedChannelId}&maxResults=${encodedMaxResults}&order=date&type=video&videoDuration=medium&key=${apiKey}`, { signal: controller.signal }),
fetch(`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${encodedChannelId}&maxResults=${encodedMaxResults}&order=date&type=video&videoDuration=long&key=${apiKey}`, { signal: controller.signal })
])
clearTimeout(timeoutId)
if (!mediumResponse.ok || !longResponse.ok) {
const failedResponse = !mediumResponse.ok ? mediumResponse : longResponse
throw new Error(`YouTube API error: ${failedResponse.status}`)
}
const [mediumData, longData] = await Promise.all([
mediumResponse.json(),
longResponse.json()
])
if (!mediumData || typeof mediumData !== 'object' || !Array.isArray(mediumData.items)) {
throw new Error('Invalid response format from YouTube API (medium)')
}
if (!longData || typeof longData !== 'object' || !Array.isArray(longData.items)) {
throw new Error('Invalid response format from YouTube API (long)')
}
const combinedItems = [...(mediumData.items || []), ...(longData.items || [])]
combinedItems.sort((a, b) => {
const dateA = new Date(a.snippet?.publishedAt || 0)
const dateB = new Date(b.snippet?.publishedAt || 0)
return dateB - dateA
})
const limitedItems = combinedItems.slice(0, parseInt(maxResults, 10))
const outputData = { ...mediumData, items: limitedItems }
decodeSnippetFields(outputData)
const response = new Response(JSON.stringify(outputData), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=86400'
}
})
await caches.default.put(request, response.clone())
return response
}
} catch (fetchError) {
clearTimeout(timeoutId)
if (fetchError.name === 'AbortError') {
throw new Error('Request timeout')
}
throw fetchError
}
} catch (error) {
const errorMessage = error.message || 'Internal server error'
return new Response(JSON.stringify({
error: 'Internal server error',
message: errorMessage.includes('API key') ? 'Configuration error' : 'An error occurred'
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
})
}
}
}Webflow Configuration
Add this in your Webflow page Head Code:
<!-- BeBranded Contents -->
<script async src="https://cdn.jsdelivr.net/npm/@bebranded/bb-contents@latest/bb-contents.js"></script>
<!-- Youtube Integration by bb-contents -->
<script>
window._bbContentsConfig = {
youtubeEndpoint: 'YOUR CLOUDFLARE WORKER URL HERE'
};
</script>Create a grid wrapper div in Webflow.
Add bb-youtube-channel="UC_YOUR_CHANNEL_ID" on the wrapper.
Inside, add a template element with bb-youtube-item.
Inside the template, mark thumbnail, title, description, date, etc.
See section 4 for every available attribute.
Clone the ready-made Webflow project (recommended)
The fastest way to get started: clone our Made in Webflow project.
The grid structure, attributes, and styling are already configured.
Clone it, paste your Worker URL in the Head Code, update your channel ID, and publish.
https://webflow.com/made-in-webflow/website/bb-contents-youtube-grid
After cloning: open Project Settings → Custom Code → Head Code, paste the script above, and replace the Worker URL.
You can also copy individual sections into your own project.
Module customization
How attributes work
Add custom attributes on Webflow elements (Element settings → Custom attributes).
Grid-level attributes go on the outer wrapper. Template attributes go inside bb-youtube-item.
The module fetches videos via your Cloudflare Worker and fills each cloned template automatically.
Channel ID (grid wrapper)
Value: one or more YouTube channel IDs, comma-separated.
Find your channel ID in YouTube Studio → Settings → Channel → Advanced settings.
Do not confuse with bb-youtube-channel inside the template (that one shows the channel name).
Number of videos in the grid
Default is 10 if omitted. Any positive number works.
The Worker caps requests at 50 (YouTube API limit).
Skip first videos
Example: bb-youtube-skip="1" hides the latest upload and shows the next ones.
Useful to pin a featured video separately or exclude fresh content.
Allowing Youtube Shorts?
false (default behaviour): only medium and long videos — Shorts are excluded.
true: includes YouTube Shorts in the grid.
Module language
Supported: French (fr) — default, English (en).
Grid container (optional)
The module clones the template inside this container element.
Leave empty if bb-youtube-item sits directly inside the grid wrapper.
Video template (required)
Add bb-youtube-item on the element that will be duplicated for each video.
Works on a link block, div, or any wrapper.
If the template is a link block, the YouTube watch URL is set automatically on href.
Hide this template in Webflow (display: none) — the module clones it for each video.
Video thumbnail
No value needed — src and alt are filled automatically.
Uses the best available thumbnail quality (up to 1280×720).
Video title
Displays the video title from the YouTube API.
HTML entities (&, ', etc.) are decoded automatically.
Video description
Displays the video description from YouTube.
Long descriptions may need CSS (line-clamp, max-height) for layout control.
Publication date
Displays a relative date (e.g. "3 days ago").
Language follows bb-youtube-language.
Channel name (in template)
Add on a Text element to display the YouTube channel name.
Different from bb-youtube-channel on the grid wrapper.





