Auto Youtube Grid on Webflow

Auto Youtube Grid on Webflow

This tool automatically pulls the latest videos from any YouTube channel and displays them in a professional grid format. Simply provide a YouTube channel ID and the module handles the rest: fetching video data, thumbnails, titles, descriptions, and publication dates. Features include smart caching to minimize API calls, bot detection to prevent unnecessary requests, Shorts filtering options, multi-language date formatting, and high-quality thumbnail optimization. Perfect for portfolio showcases, content marketing, video galleries and dynamic YouTube integrations without complex coding.
Setup time:
10min
Difficulty:
Easy
Share this feature
Youtube Grid on Webflow
1

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

2

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(/&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': '*'
        }
      })
    }
  }
}
NOTE
Do not put your API key in the worker code — it is read from the YOUTUBE_API_KEY secret. After deploying, copy your Worker URL.
3

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>
NOTE
Replace YOUR CLOUDFLARE WORKER URL HERE with your deployed Worker URL.
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.

NOTE
Made in Webflow (free clone):
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.
4

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.

NOTE
Tip: see the clone card in section 3 for a working Webflow example.

Channel ID (grid wrapper)

bb-youtube-channel="UC_YOUR_CHANNEL_ID"
Add this attribute:
Name
bb-youtube-channel
Value
UC_YOUR_CHANNEL_ID
NOTE
On the main grid wrapper — not inside the template.
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

bb-youtube-video-count="9"
Add this attribute:
Name
bb-youtube-video-count
Value
9
NOTE
How many videos to display in the grid.
Default is 10 if omitted. Any positive number works.
The Worker caps requests at 50 (YouTube API limit).

Skip first videos

bb-youtube-skip="0"
Add this attribute:
Name
bb-youtube-skip
Value
0
NOTE
Skip the N most recent videos before displaying.
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?

bb-youtube-allow-shorts="true"
Add this attribute:
Name
bb-youtube-allow-shorts
Value
true
NOTE
Values: true or false.
false (default behaviour): only medium and long videos — Shorts are excluded.
true: includes YouTube Shorts in the grid.

Module language

bb-youtube-language="en"
Add this attribute:
Name
bb-youtube-language
Value
en
NOTE
Controls relative date formatting (bb-youtube-date).
Supported: French (fr) — default, English (en).

Grid container (optional)

bb-youtube-container=" "
Add this attribute:
Name
bb-youtube-container
Value
NOTE
Optional. Only needed if bb-youtube-item is nested one level deeper than the grid wrapper.
The module clones the template inside this container element.
Leave empty if bb-youtube-item sits directly inside the grid wrapper.

Video template (required)

bb-youtube-item=" "

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.

Add this attribute:
Name
bb-youtube-item
Value
NOTE
Inside bb-youtube-item, add the attributes below. See section 3 for the full Webflow structure.

Video thumbnail

bb-youtube-thumbnail=" "
Add this attribute:
Name
bb-youtube-thumbnail
Value
NOTE
Add on an Image element inside bb-youtube-item.
No value needed — src and alt are filled automatically.
Uses the best available thumbnail quality (up to 1280×720).

Video title

bb-youtube-title=" "
Add this attribute:
Name
bb-youtube-title
Value
NOTE
Add on a Text Block or Heading inside bb-youtube-item.
Displays the video title from the YouTube API.
HTML entities (&amp;, &#39;, etc.) are decoded automatically.

Video description

bb-youtube-description=" "
Add this attribute:
Name
bb-youtube-description
Value
NOTE
Add on a Text Block inside bb-youtube-item.
Displays the video description from YouTube.
Long descriptions may need CSS (line-clamp, max-height) for layout control.

Publication date

bb-youtube-date=" "
Add this attribute:
Name
bb-youtube-date
Value
NOTE
Add on a Text element inside bb-youtube-item.
Displays a relative date (e.g. "3 days ago").
Language follows bb-youtube-language.

Channel name (in template)

bb-youtube-channel=" "
Add this attribute:
Name
bb-youtube-channel
Value
NOTE
Inside bb-youtube-item only — not the channel ID.
Add on a Text element to display the YouTube channel name.
Different from bb-youtube-channel on the grid wrapper.
Still need help?