diff --git a/spec/helpers/vtt/builder_spec.cr b/spec/helpers/vtt/builder_spec.cr
new file mode 100644
index 00000000..69303bab
--- /dev/null
+++ b/spec/helpers/vtt/builder_spec.cr
@@ -0,0 +1,64 @@
+require "../../spec_helper.cr"
+
+MockLines = [
+  {
+    "start_time": Time::Span.new(seconds: 1),
+    "end_time":   Time::Span.new(seconds: 2),
+    "text":       "Line 1",
+  },
+
+  {
+    "start_time": Time::Span.new(seconds: 2),
+    "end_time":   Time::Span.new(seconds: 3),
+    "text":       "Line 2",
+  },
+]
+
+Spectator.describe "WebVTT::Builder" do
+  it "correctly builds a vtt file" do
+    result = WebVTT.build do |vtt|
+      MockLines.each do |line|
+        vtt.line(line["start_time"], line["end_time"], line["text"])
+      end
+    end
+
+    expect(result).to eq([
+      "WEBVTT",
+      "",
+      "00:00:01.000 --> 00:00:02.000",
+      "Line 1",
+      "",
+      "00:00:02.000 --> 00:00:03.000",
+      "Line 2",
+      "",
+      "",
+    ].join('\n'))
+  end
+
+  it "correctly builds a vtt file with setting fields" do
+    setting_fields = {
+      "Kind"     => "captions",
+      "Language" => "en",
+    }
+
+    result = WebVTT.build(setting_fields) do |vtt|
+      MockLines.each do |line|
+        vtt.line(line["start_time"], line["end_time"], line["text"])
+      end
+    end
+
+    expect(result).to eq([
+      "WEBVTT",
+      "Kind: captions",
+      "Language: en",
+      "",
+      "00:00:01.000 --> 00:00:02.000",
+      "Line 1",
+      "",
+      "00:00:02.000 --> 00:00:03.000",
+      "Line 2",
+      "",
+      "",
+    ].join('\n'))
+  end
+end
diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr
new file mode 100644
index 00000000..7d9d5f1f
--- /dev/null
+++ b/src/invidious/helpers/webvtt.cr
@@ -0,0 +1,67 @@
+# Namespace for logic relating to generating WebVTT files
+#
+# Probably not compliant to WebVTT's specs but it is enough for Invidious.
+module WebVTT
+  # A WebVTT builder generates WebVTT files
+  private class Builder
+    def initialize(@io : IO)
+    end
+
+    # Writes an vtt line with the specified time stamp and contents
+    def line(start_time : Time::Span, end_time : Time::Span, text : String)
+      timestamp(start_time, end_time)
+      @io << text
+      @io << "\n\n"
+    end
+
+    private def timestamp(start_time : Time::Span, end_time : Time::Span)
+      add_timestamp_component(start_time)
+      @io << " --> "
+      add_timestamp_component(end_time)
+
+      @io << '\n'
+    end
+
+    private def add_timestamp_component(timestamp : Time::Span)
+      @io << timestamp.hours.to_s.rjust(2, '0')
+      @io << ':' << timestamp.minutes.to_s.rjust(2, '0')
+      @io << ':' << timestamp.seconds.to_s.rjust(2, '0')
+      @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0')
+    end
+
+    def document(setting_fields : Hash(String, String)? = nil, &)
+      @io << "WEBVTT\n"
+
+      if setting_fields
+        setting_fields.each do |name, value|
+          @io << "#{name}: #{value}\n"
+        end
+      end
+
+      @io << '\n'
+
+      yield
+    end
+  end
+
+  # Returns the resulting `String` of writing WebVTT to the yielded WebVTT::Builder
+  #
+  # ```
+  # string = WebVTT.build do |io|
+  #   vtt.line(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1")
+  #   vtt.line(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2")
+  # end
+  #
+  # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n"
+  # ```
+  #
+  # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file.
+  def self.build(setting_fields : Hash(String, String)? = nil, &)
+    String.build do |str|
+      builder = Builder.new(str)
+      builder.document(setting_fields) do
+        yield builder
+      end
+    end
+  end
+end