Introduction# Video content is essential for technical blogs, but embedding videos in static sites like Hugo requires careful consideration of performance, accessibility, and user experience. This guide covers everything from self-hosted videos to external embeds.
The Challenge with Static Site Videos# Static site generators excel at text and images, but video presents unique challenges:
File size : Video files are large and can slow down your siteBandwidth costs : Self-hosting videos can be expensiveBrowser compatibility : Different formats for different browsersPerformance : Videos can block page renderingSEO : Proper video markup for search enginesCustom Video Shortcode# I’ve created a flexible Hugo shortcode that handles multiple video sources:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
{{/*
Video shortcode for embedding self-hosted and external videos
Usage:
< div class = "video-container" >
< video
controls
poster = "/images/video-poster.jpg"
preload = "metadata"
style = "width: 100%; height: auto; max-width: 100%;"
>
< source src = "/videos/demo.mp4" type = "video/mp4" >
< p > Your browser doesn't support HTML5 video.
< a href = "/videos/demo.mp4" > Download the video</ a > instead.
</ p >
</ video >
</ div >
< style >
. video-container {
margin : 1.5 rem 0 ;
}
. video-wrapper {
border-radius : 8 px ;
overflow : hidden ;
box-shadow : 0 4 px 6 px rgba ( 0 , 0 , 0 , 0.1 );
}
. video-container video {
border-radius : 8 px ;
box-shadow : 0 4 px 6 px rgba ( 0 , 0 , 0 , 0.1 );
}
@ media ( max-width : 768px ) {
. video-container {
margin : 1 rem -1 rem ;
}
. video-container video ,
. video-wrapper {
border-radius : 0 ;
}
}
</ style >
< div class = "video-container" >
< div class = "video-wrapper" style = "position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;" >
< iframe
src = "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0&showinfo=0&modestbranding=1"
style = "position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder = "0"
allowfullscreen
loading = "lazy"
title = "YouTube video" >
</ iframe >
</ div >
</ div >
< style >
. video-container {
margin : 1.5 rem 0 ;
}
. video-wrapper {
border-radius : 8 px ;
overflow : hidden ;
box-shadow : 0 4 px 6 px rgba ( 0 , 0 , 0 , 0.1 );
}
. video-container video {
border-radius : 8 px ;
box-shadow : 0 4 px 6 px rgba ( 0 , 0 , 0 , 0.1 );
}
@ media ( max-width : 768px ) {
. video-container {
margin : 1 rem -1 rem ;
}
. video-container video ,
. video-wrapper {
border-radius : 0 ;
}
}
</ style >
< div class = "video-container" >
< div class = "video-wrapper" style = "position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;" >
< iframe
src = "https://player.vimeo.com/video/123456789?dnt=1&title=0&byline=0&portrait=0"
style = "position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder = "0"
allowfullscreen
loading = "lazy"
title = "Vimeo video" >
</ iframe >
</ div >
</ div >
< style >
. video-container {
margin : 1.5 rem 0 ;
}
. video-wrapper {
border-radius : 8 px ;
overflow : hidden ;
box-shadow : 0 4 px 6 px rgba ( 0 , 0 , 0 , 0.1 );
}
. video-container video {
border-radius : 8 px ;
box-shadow : 0 4 px 6 px rgba ( 0 , 0 , 0 , 0.1 );
}
@ media ( max-width : 768px ) {
. video-container {
margin : 1 rem -1 rem ;
}
. video-container video ,
. video-wrapper {
border-radius : 0 ;
}
}
</ style >
*/}}
{{ $src := .Get "src" }}
{{ $poster := .Get "poster" }}
{{ $youtube := .Get "youtube" }}
{{ $vimeo := .Get "vimeo" }}
{{ $caption := .Get "caption" }}
< div class = "video-container" >
{{ if $youtube }}
<!-- YouTube embed with privacy-enhanced mode -->
< div class = "video-wrapper" >
< iframe
src = "https://www.youtube-nocookie.com/embed/{{ $youtube }}?rel=0&showinfo=0"
frameborder = "0"
allowfullscreen
loading = "lazy" >
</ iframe >
</ div >
{{ else if $src }}
<!-- Self-hosted video with multiple formats -->
< video controls preload = "metadata" poster = "{{ $poster | relURL }}" >
< source src = "{{ $src | relURL }}" type = "video/mp4" >
< p > Your browser doesn't support HTML5 video.</ p >
</ video >
{{ end }}
</ div >
Usage Examples# Self-Hosted Videos# For short clips and demos, self-hosting gives you complete control:
1
Your browser doesn't support HTML5 video.
Download the video instead.
CDK deployment in action
Best practices for self-hosted videos:
Optimize file size : Use H.264 codec with appropriate bitrateMultiple formats : Provide WebM for better compressionPoster images : Always include a poster for faster loadingCloudFront delivery : Serve videos through CDNYouTube Embeds# For longer content, YouTube provides free hosting and global CDN:
1
AWS re:Invent 2024 Keynote Highlights
YouTube embed benefits:
Free hosting and bandwidth Automatic quality adaptation Mobile-optimized player Analytics and engagement metrics Vimeo Embeds# For professional content with better privacy controls:
1
Advanced CDK Patterns Workshop
Video Optimization Strategy# File Preparation# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/bin/bash
# Optimize video files for web delivery
INPUT_FILE = " $1 "
OUTPUT_DIR = "static/videos"
# Create MP4 with H.264 (widely compatible)
ffmpeg -i " $INPUT_FILE " \
-c:v libx264 \
-preset slow \
-crf 23 \
-c:a aac \
-b:a 128k \
-movflags +faststart \
" $OUTPUT_DIR / $( basename " $INPUT_FILE " .mov) .mp4"
# Create WebM with VP9 (better compression)
ffmpeg -i " $INPUT_FILE " \
-c:v libvpx-vp9 \
-crf 30 \
-b:v 0 \
-c:a libopus \
-b:a 128k \
" $OUTPUT_DIR / $( basename " $INPUT_FILE " .mov) .webm"
# Generate poster image
ffmpeg -i " $INPUT_FILE " \
-ss 00:00:02 \
-vframes 1 \
-q:v 2 \
"static/images/ $( basename " $INPUT_FILE " .mov) -poster.jpg"
CloudFront Configuration# For self-hosted videos, configure CloudFront for optimal delivery:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// CloudFront behavior for video files
{
pathPattern : "*.mp4" ,
origin : new origins . S3Origin ( bucket ),
viewerProtocolPolicy : cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS ,
cachePolicy : new cloudfront . CachePolicy ( this , 'VideoCachePolicy' , {
cachePolicyName : 'VideoOptimized' ,
defaultTtl : Duration.days ( 30 ),
maxTtl : Duration.days ( 365 ),
minTtl : Duration.days ( 1 ),
// Enable byte-range requests for video seeking
headerBehavior : cloudfront.CacheHeaderBehavior.allowList (
'Range' ,
'If-Range'
)
}),
compress : false , // Don't compress video files
allowedMethods : cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS
}
Lazy Loading# The shortcode includes loading="lazy" for iframe embeds, but for self-hosted videos:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
< video
controls
preload = "none"
poster = "{{ $poster }}"
data-src = "{{ $src }}"
class = "lazy-video" >
< source data-src = "{{ $src }}" type = "video/mp4" >
</ video >
< script >
// Intersection Observer for lazy video loading
const lazyVideos = document . querySelectorAll ( '.lazy-video' );
const videoObserver = new IntersectionObserver (( entries ) => {
entries . forEach ( entry => {
if ( entry . isIntersecting ) {
const video = entry . target ;
video . src = video . dataset . src ;
video . load ();
videoObserver . unobserve ( video );
}
});
});
lazyVideos . forEach ( video => videoObserver . observe ( video ));
</ script >
Bandwidth Optimization# Adaptive bitrate : Use multiple quality versionsFormat selection : Serve WebM to supporting browsersCDN delivery : Always use CloudFront for global deliveryCompression : Optimize without sacrificing qualitySEO and Accessibility# Video Schema Markup# Add structured data for better SEO:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
< script type = "application/ld+json" >
{
"@context" : "https://schema.org" ,
"@type" : "VideoObject" ,
"name" : "{{ $caption }}" ,
"description" : "{{ .Page.Summary }}" ,
"thumbnailUrl" : "{{ $poster | absURL }}" ,
"uploadDate" : "{{ .Page.Date.Format " 2006 - 01 - 02 T15 : 04 : 05 Z07 : 00 " }}" ,
"contentUrl" : "{{ $src | absURL }}" ,
"embedUrl" : "{{ .Page.Permalink }}" ,
"duration" : "PT2M30S"
}
</ script >
Accessibility Features# Captions : Always provide captions for accessibilityTranscripts : Include text transcripts below videosKeyboard navigation : Ensure video controls are keyboard accessibleScreen reader support : Proper ARIA labels and descriptionsCost Analysis# Self-Hosted vs External# Self-hosted costs (AWS):
S3 storage: $0.023/GB/month CloudFront data transfer: $0.085/GB (first 10TB) Requests: $0.0075/10,000 requests External hosting:
YouTube: Free (with ads) or YouTube Premium Vimeo: $7/month for basic, $20/month for Pro Recommendation# Short demos (<2 minutes) : Self-host with CloudFrontTutorials (>5 minutes) : YouTube for reach, Vimeo for professionalMarketing content : YouTube for SEO benefitsInternal training : Self-host for privacyAdvanced Techniques# Progressive Enhancement# Start with a poster image and load video on interaction:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ProgressiveVideo {
constructor ( element ) {
this . container = element ;
this . poster = element . querySelector ( '.video-poster' );
this . videoData = JSON . parse ( element . dataset . video );
this . poster . addEventListener ( 'click' , () => this . loadVideo ());
}
loadVideo () {
const video = document . createElement ( 'video' );
video . controls = true ;
video . autoplay = true ;
video . src = this . videoData . src ;
this . container . replaceChild ( video , this . poster );
}
}
// Initialize progressive videos
document . querySelectorAll ( '.progressive-video' )
. forEach ( el => new ProgressiveVideo ( el ));
Video Analytics# Track video engagement with custom events:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function trackVideoEvents ( video ) {
let quartiles = [ 25 , 50 , 75 , 100 ];
let trackedQuartiles = [];
video . addEventListener ( 'play' , () => {
gtag ( 'event' , 'video_start' , {
video_title : video . dataset . title ,
video_url : video . src
});
});
video . addEventListener ( 'timeupdate' , () => {
const percent = ( video . currentTime / video . duration ) * 100 ;
quartiles . forEach ( quartile => {
if ( percent >= quartile && ! trackedQuartiles . includes ( quartile )) {
trackedQuartiles . push ( quartile );
gtag ( 'event' , 'video_progress' , {
video_title : video . dataset . title ,
progress : quartile
});
}
});
});
}
Conclusion# Video embedding in Hugo requires balancing performance, cost, and user experience. The custom shortcode approach gives you flexibility while maintaining optimal performance through:
Smart loading strategies : Lazy loading and progressive enhancementMultiple format support : MP4 for compatibility, WebM for efficiencyCDN optimization : CloudFront configuration for global deliverySEO integration : Structured data and accessibility featuresChoose your video strategy based on content type, audience, and budget. Self-host short demos, use YouTube for reach, and Vimeo for professional content.
Want to see more Hugo advanced techniques? Follow me on LinkedIn for updates on static site optimization.