Generate YouTube thumbnails with Workers and Cloudflare Image Resizing
Last updated Oct 12, 2026 In this tutorial, you will learn how to programmatically generate a custom YouTube thumbnail using Cloudflare Workers and Cloudflare Image Resizing. You may want to generate a custom YouTube thumbnail to customize the thumbnail's design, call-to-actions and images used to encourage more viewers to watch your video.
This tutorial will help you understand how to work with Images, Image Resizing and Cloudflare Workers.
Before you start
All of the tutorials assume you have already completed the Get started guide, which gets you set up with a Cloudflare Workers account, C3 ↗︎, and Wrangler.
To follow this tutorial, make sure you have Node, Cargo, and Wrangler installed on your machine.
Learning goals
In this tutorial, you will learn how to:
- Upload Images to Cloudflare with the Cloudflare dashboard or API.
- Set up a Worker project with Wrangler.
- Manipulate images with image transformations in your Worker.
Upload your image
To generate a custom thumbnail image, you first need to upload a background image to Cloudflare Images. This will serve as the image you use for transformations to generate the thumbnails.
Cloudflare Images allows you to store, resize, optimize and deliver images in a fast and secure manner. To get started, upload your images to the Cloudflare dashboard or use the Upload API.
Upload with the dashboard
To upload an image using the Cloudflare dashboard:
- In the Cloudflare dashboard, go to the Transformations page. Go to Transformations ↗
- Use Quick Upload to either drag and drop an image or click to browse and choose a file from your local files.
- After the image is uploaded, view it using the generated URL.
Upload with the API
To upload your image with the Upload via URL API, refer to the example below:
curl --request POST \
--url https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/images/v1 \
--header 'Authorization: Bearer <API_TOKEN>' \
--form 'url=<PATH_TO_IMAGE>' \
--form 'metadata={"key":"value"}' \
--form 'requireSignedURLs=false'
ACCOUNT_ID: The current user's account id which can be found in your account settings.API_TOKEN: Needs to be generated to scoping Images permission.PATH_TO_IMAGE: Indicates the URL for the image you want to upload.
You will then receive a response similar to this:
{
"result": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "image.jpeg",
"metadata": {
"key": "value"
},
"uploaded": "2022-01-31T16:39:28.458Z",
"requireSignedURLs": false,
"variants": [
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901/public",
"https://imagedelivery.net/Vi7wi5KSItxGFsWRG2Us6Q/2cdc28f0-017a-49c4-9ed7-87056c83901/thumbnail"
]
},
"success": true,
"errors": [],
"messages": []
}
Now that you have uploaded your image, you will use it as the background image for your video's thumbnail.
Create a Worker to transform text to image
After uploading your image, create a Worker that will enable you to transform text to image. This image can be used as an overlay on the background image you uploaded. Use the rustwasm-worker-template ↗︎.
You will need the following before you begin:
- A recent version of Rust ↗︎.
- Access to the
cargo-generatesubcommand:
cargo install cargo-generate
```
Create a new Worker project using the `worker-rust` template:
```sh
cargo generate https://github.com/cloudflare/rustwasm-worker-template
You will now make a few changes to the files in your project directory.
- In the
lib.rsfile, add the following code block:
use worker::*;
mod utils;
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> {
// Optionally, get more helpful error messages written to the console in the case of a panic.
utils::set_panic_hook();
let router = Router::new();
router
.get("/", |_, _| Response::ok("Hello from Workers!"))
.run(req, env)
.await
}
- Update the
Cargo.tomlfile in yourworker-to-textproject directory to use text-to-png ↗︎, a Rust package for rendering text to PNG. Add the package as a dependency by running:
cargo add text-to-png@0.2.0
- Import the
text_to_pnglibrary into yourworker-to-textproject'slib.rsfile.
use text_to_png::{TextPng, TextRenderer};
use worker::*;
mod utils;
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> {
// Optionally, get more helpful error messages written to the console in the case of a panic.
utils::set_panic_hook();
let router = Router::new();
router
.get("/", |_, _| Response::ok("Hello from Workers!"))
.run(req, env)
.await
}
- Update
lib.rsto create ahandle-slashfunction that will activate the image transformation based on the text passed to the URL as a query parameter.
use text_to_png::{TextPng, TextRenderer};
use worker::*;
mod utils;
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> {
// Optionally, get more helpful error messages written to the console in the case of a panic.
utils::set_panic_hook();
let router = Router::new();
router
.get("/", |_, _| Response::ok("Hello from Workers!"))
.run(req, env)
.await
}
async fn handle_slash(text: String) -> Result<Response> {}
- In the
handle-slashfunction, call theTextRendererby assigning it to a renderer value, specifying that you want to use a custom font. Then, use therender_text_to_png_datamethod to transform the text into image format. In this example, the custom font (Inter-Bold.ttf) is located in an/assetsfolder at the root of the project which will be used for generating the thumbnail. You must update this portion of the code to point to your custom font file.
use text_to_png::{TextPng, TextRenderer};
use worker::*;
mod utils;
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> {
// Optionally, get more helpful error messages written to the console in the case of a panic.
utils::set_panic_hook();
let router = Router::new();
router
.get("/", |_, _| Response::ok("Hello from Workers!"))
.run(req, env)
.await
}
async fn handle_slash(text: String) -> Result<Response> {
let renderer = TextRenderer::try_new_with_ttf_font_data(include_bytes!("../assets/Inter-Bold.ttf"))
.expect("Example font is definitely loadable");
let text_png: TextPng = renderer.render_text_to_png_data(text.replace("+", " "), 60, "003682").unwrap();
}
- Rewrite the
Routerfunction to callhandle_slashwhen a query is passed in the URL, otherwise return the"Hello Worker!"as the response.
use text_to_png::{TextPng, TextRenderer};
use worker::*;
mod utils;
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> {
// Optionally, get more helpful error messages written to the console in the case of a panic.
utils::set_panic_hook();
let router = Router::new();
router
.get_async("/", |req, _| async move {
if let Some(text) = req.url()?.query() {
handle_slash(text.into()).await
} else {
handle_slash("Hello Worker!".into()).await
}
})
.run(req, env)
.await
}
async fn handle_slash(text: String) -> Result<Response> {
let renderer = TextRenderer::try_new_with_ttf_font_data(include_bytes!("../assets/Inter-Bold.ttf"))
.expect("Example font is definitely loadable");
let text_png: TextPng = renderer.render_text_to_png_data(text.replace("+", " "), 60, "003682").unwrap();
}
- In your
lib.rsfile, set the headers tocontent-type: image/pngso that the response is correctly rendered as a PNG image.
use text_to_png::{TextPng, TextRenderer};
use worker::*;
mod utils;
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> {
// Optionally, get more helpful error messages written to the console in the case of a panic.
utils::set_panic_hook();
let router = Router::new();
router
.get_async("/", |req, _| async move {
if let Some(text) = req.url()?.query() {
handle_slash(text.into()).await
} else {
handle_slash("Hello Worker!".into()).await
}
})
.run(req, env)
.await
}
async fn handle_slash(text: String) -> Result<Response> {
let renderer = TextRenderer::try_new_with_ttf_font_data(include_bytes!("../assets/Inter-Bold.ttf"))
.expect("Example font is definitely loadable");
let text_png: TextPng = renderer.render_text_to_png_data(text.replace("+", " "), 60, "003682").unwrap();
let mut headers = Headers::new();
headers.set("content-type", "image/png")?;
Ok(Response::from_bytes(text_png.data)?.with_headers(headers))
}
The final lib.rs file should look as follows. Find the full code as an example repository on GitHub ↗︎.
```rs use text_to_png::{TextPng, TextRenderer}; use worker::*;
mod utils;
[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result<Response> { // Optionally, get more helpful error messages written to the console in the case of a panic. utils::set_panic_hook();
let router = Router::new();
router .get_async("/", |req, _| async move { if let Some(text) = req.url()?.query() { handle_slash(text.into()).await } else { handle_slash("Hello Worker!".into()).await } }) .run(req, env) .await }
async fn handle_slash(text: String) -> Result<Response> { let renderer = TextRenderer::try_new_with_ttf_font_data(include_bytes!("../assets/Inter-Bold.ttf")) .expect("Example font is definitely loadable");
let text = if text.len() > 128 { "Nope".into() } else { text };
