Youtube Grid

BeBranded Contents brings powerful, no-code features to your Webflow sites through simple HTML attributes. Add social sharing, dynamic favicons and more with just a single script. Designed in France, built for the global Webflow community.

Designed for Webflow

Easy to implement

youtube grid on webflow for free by bebranded
Obtain a Google API for Youtube
Go to this website: https://console.cloud.google.com/
Create an API key 'YouTube Data API v3'
Copy the key in the worker code below
Create a worker on Cloudflare
And paste this code inside it
function decodeHTMLEntities(str) {
  if (typeof str !== 'string') return str
  return str
    .replace(/"/g, '"')
    .replace(/'/g, "'")
    .replace(/'/g, "'")
    .replace(/&/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/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': '*'
        }
      })
    }
  }
}
Script to paste into your Webflow project → 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>