<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title><![CDATA[bunny.net Blog]]></title><description><![CDATA[Performance tips, updates, tricks and insights into our Content Delivery Network.]]></description><link>https://bunny.net/blog/</link><image><url>https://bunny.net/blog/favicon.png</url><title>bunny.net Blog</title><link>https://bunny.net/blog/</link></image><generator>Ghost 5.82</generator><lastBuildDate>Sun, 14 Jun 2026 11:22:24 GMT</lastBuildDate><atom:link href="https://bunny.net/blog/rss/" rel="self" type="application/rss+xml"/><ttl>60</ttl><item><title><![CDATA[How to tell if scrapers are eating your bandwidth]]></title><description><![CDATA[If you aren't using Bunny Shield and have noticed anomalies in your bandwidth, read this guide to diagnose what's causing it.]]></description><link>https://bunny.net/blog/how-to-tell-if-scrapers-are-eating-your-bandwidth/</link><guid isPermaLink="false">6a2c02ad160dc403fbfcf455</guid><category><![CDATA[Tips and Tricks]]></category><dc:creator><![CDATA[Dino Kukic]]></dc:creator><pubDate>Fri, 12 Jun 2026 13:05:50 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/06/how-to-tell-if-scrapers-are-eating-your-bandwidth.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/06/how-to-tell-if-scrapers-are-eating-your-bandwidth.png" alt="How to tell if scrapers are eating your bandwidth"><p><a href="https://bunny.net/shield/">Bunny Shield</a> makes identifying and blocking bots trivial. However, if you aren&#x2019;t using it and have noticed anomalies in your bandwidth, we&#x2019;ve prepped this guide to help you determine whether the reason is a pop star tweeting about your website or, unfortunately, a surge in bot traffic that&apos;s now costing you real money.</p><h2 id="not-all-bots-are-the-bad-guys">Not all bots are the bad guys</h2><p>Before you start blocking things, it&apos;s worth understanding what&apos;s actually out there. The term &quot;bot&quot; covers everything from the crawler that helps get your content in Google to the script copying your entire product catalog to sell to a competitor. Treating them all the same is how you accidentally remove your site from search results.</p><p>Here&apos;s the roughly sorted landscape.</p><h3 id="search-engine-crawlers">Search engine crawlers</h3><p><a href="https://developers.google.com/search/docs/crawling-indexing/googlebot">Googlebot</a>, <a href="https://www.bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0">Bingbot</a>, and, yeah, mostly Googlebot. They crawl and index your content and then serve it for relevant queries in the search engine results page (SERP). For the most part, you want these, and blocking them might harm your site unless you don&#x2019;t need traffic coming from search engines at all. One caveat here is that Bingbot increasingly feeds Copilot, Applebot feeds Apple Intelligence, and Google&apos;s crawl feeds Gemini. So they are being used for other things as well, and if those use cases aren&apos;t an issue, this is actually a good thing. Your site is crawled once (periodically) for multiple purposes. It become an issue when you want one, but don&#x2019;t want the other.</p><h3 id="ai-search-bots">AI search bots</h3><p><a href="https://developers.openai.com/api/docs/bots#:~:text=Description%20%26%20details-,OAI%2DSearchBot,-OAI%2DSearchBot%20is">OAI-SearchBot</a>, <a href="https://developers.openai.com/api/docs/bots#:~:text=com/gptbot.json-,ChatGPT%2DUser,-OpenAI%20also%20uses">ChatGPT-User</a>, <a href="https://docs.perplexity.ai/docs/resources/perplexity-crawlers#:~:text=Description-,PerplexityBot,-PerplexityBot%20is%20designed">PerplexityBot</a>, <a href="https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler#:~:text=directed%20web%20search.-,Claude%2DSearchBot,-Claude%2DSearchBot%20navigates">Claude-SearchBot</a>, and others. These fetch your page in real time to answer a user&apos;s current question, often with a citation back to you. They&apos;re more like search crawlers than training crawlers because they can send real visitors your way.</p><h3 id="ai-training-crawlers">AI training crawlers</h3><p><a href="https://app.notion.com/p/url">GPTBot</a>, <a href="https://app.notion.com/p/url">ClaudeBot</a>, <a href="https://app.notion.com/p/url">Meta-ExternalAgent</a>, and the rest. They collect content to train large language models. They can use real bandwidth and generally don&apos;t send traffic back the way a search crawler does, though that may be changing as AI assistants increasingly cite and link their sources. Whether that&apos;s worth the bandwidth depends on how you value having your content represented in the models.</p><h3 id="agentic-and-assistant-bots">Agentic and assistant bots</h3><p>These are bots completing a task on behalf of a specific user, such as booking, buying, comparing, summarizing, and more. These often represent a real person with real intent, so blocking them can mean blocking a customer. The category is immature and hard to identify cleanly.</p><h3 id="marketing-intelligence-crawlers">Marketing intelligence crawlers</h3><p><a href="https://ahrefs.com/robot#:~:text=Our%20bots-,AhrefsBot,-User%2Dagent%20string">AhrefsBot</a>, <a href="https://www.semrush.com/bot/">SemrushBot</a>, and similar. They crawl your site to build the commercial SEO datasets behind tools marketers use to track rankings, find backlinks, and size up competitors. The value to you is indirect and slightly circular. These crawlers help build the same SEO datasets you might use to analyze competitors. Whether that&#x2019;s worth the crawl is a judgment call.</p><h3 id="social-link-preview-bots">Social / link-preview bots</h3><p><a href="https://developers.facebook.com/docs/sharing/webmasters/web-crawlers/#:~:text=with%20your%20site.-,FacebookExternalHit,-The%20primary%20purpose">FacebookExternalHit</a>, Twitterbot, LinkedInBot, Slackbot, Discordbot, etc. They fetch a page to build the preview card when someone shares your link. These are triggered by real people sharing your content. If you block them, your links may render as ugly bare URLs everywhere. They&apos;re generally low volume but potentially high value.</p><h3 id="commercial-vertical-scrapers">Commercial / vertical scrapers</h3><p>Price-comparison engines, job aggregators, review aggregators. Whether you want them depends entirely on your business. A price comparison bot is great if you want to be compared and a problem if a competitor is using it to undercut you.</p><h3 id="research-archival-crawlers">Research / archival crawlers</h3><p>The Internet Archive&apos;s crawler, academic datasets, and preservation projects. Usually benign and often a public good. Common Crawl is the one that complicates the picture a little bit because it&apos;s a long-running open dataset used widely in research, but it&apos;s also been a common source of training data for language models. This means that site owners who want to limit AI training sometimes block its crawler (<a href="https://commoncrawl.org/ccbot">CCBot</a>) too, even though its purpose is broader than that.</p><h3 id="monitoring-bots">Monitoring bots</h3><p>Uptime checkers (Pingdom, UptimeRobot), performance monitors (Catchpoint, Datadog) and your own health checks. You want the ones you recognize, especially your own. However, those are usually low volume and mostly harmless.</p><h3 id="malicious-bots">Malicious bots</h3><p>Content thieves, hostile price and data scrapers, credential-stuffing, and brute-force bots, vulnerability scanners, spam bots, and inventory-hoarding scalpers. This is usually what you want to identify, and the only hard part is detecting them because they actively try to look like everything else on this list.</p><p>So the most important thing is that any of these can be faked**.** A request claiming to be Googlebot might be a scraper wearing a sheep&#x2019;s skin.</p><h2 id="how-to-diagnose-it-from-your-logs">How to diagnose it from your logs</h2><p>Everything below runs against standard access logs. Just adjust field positions for your log format. Also, all of these are artificially generated examples, so real logs will have a little bit more nuance.</p><h3 id="growth-or-lack-of-in-analytics-sessions-and-conversions">Growth (or lack of) in analytics sessions and conversions</h3><p>Before you dig into the logs, you can just look at your bandwidth compared to analytics sessions. For example:</p>
<!--kg-card-begin: html-->
<table style="border:1px solid rgba(24, 61, 109, 0.18); border-collapse:collapse; width:100%;"> <thead> <tr style="font-weight:700; text-align:left; background:#223c6a; color:white;"> <th style="padding:12px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;"> </th> <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;"> Last month </th> <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;"> This month </th> <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); color:white;"> Change </th> </tr> </thead> <tbody> <tr> <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Bandwidth</td> <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">2.1 TB</td> <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">3.8 TB</td> <td style="padding:12px 14px; vertical-align:middle; color:#183d6d;"><strong>+81%</strong></td> </tr> <tr> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Analytics sessions</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">48,200</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">49,100</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;"><strong>+2%</strong></td> </tr> <tr> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Conversions</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">1,840</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">1,810</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;"><strong>-2%</strong></td> </tr> </tbody> </table>
<!--kg-card-end: html-->
<p>Bandwidth is up 81% while sessions remain roughly flat. Most analytics tools run via JavaScript, and most bots don&apos;t execute JavaScript, so they&apos;re invisible to your analytics but still consume bandwidth.</p><h3 id="request-rate-per-client">Request rate per client</h3><p>Find out who&apos;s making the most requests:</p><pre><code>awk &apos;{print $1}&apos; access.log | sort | uniq -c | sort -rn | head -10
</code></pre><pre><code>  84213 47.128.44.19
  61887 47.128.44.20
  58122 47.128.44.21
   9043 66.249.66.1
   2287 81.150.12.4
    412 81.150.12.4
    389 92.40.177.22
    301 213.205.241.9
    288 51.171.38.4
    274 78.149.203.11
</code></pre><p>The top three IPs sit in one tight block, <code>47.128.44.x,</code> and each is making 50,000-80,000 requests, while your actual visitors trail off into the low hundreds. The 9,043 requests from <code>66.249.66.1</code> are from Googlebot (we&#x2019;ll go through its legitimacy later). The three at the top are a coordinated scrape from a single subnet.</p><p>One thing worth doing prior to actually eyeballing this is calculating the median requests per IP first, as this will give you a baseline for what traffic normally looks like on your website. You want to identify clients making 10 or 100 times the median.</p><h3 id="the-asset-loading-signature">The asset-loading signature</h3><p>This is one of the cleanest tells you have. When a real browser loads a page, it fetches the HTML and then everything the page references, such as CSS, JavaScript, fonts, and images. A scraper usually grabs only the HTML.</p><p>Pull everything requested by a suspect IP:</p><pre><code>grep &quot;^47.128.44.19&quot; access.log | awk &apos;{print $7}&apos; | sort | uniq -c | sort -rn | head -10
</code></pre><p>For the scraper, it&apos;s all pages, no assets:</p><pre><code>   2104 /products/12841
   2103 /products/12842
   2101 /products/12843
   2099 /products/12844
</code></pre><p>Now run the same command against a real visitor, and the shape is completely different:</p><pre><code>     14 /products/12841
      9 /css/main.a3f9.css
      9 /js/app.8c21.js
      7 /fonts/inter.woff2
     22 /images/product-12841-thumb.webp
      6 /api/cart/count
</code></pre><p>The real browser pulls the page plus its stylesheet, scripts, fonts, images, and a cart API call, so practically everything needed to actually render and use the page. The scraper pulls pages and nothing else. That ratio, HTML-only versus HTML-plus-assets, is hard to fake convincingly because faking it means doing real rendering work, which defeats the point of scraping cheaply.</p><h3 id="no-javascript-execution">No JavaScript execution</h3><p>This is closely related, but it&apos;s worth confirming separately. Most scrapers don&apos;t run JavaScript at all, so they never hit the endpoints your frontend fires after the page loads, such as analytics scripts, lazy-loaded API calls, and tracking pixels.</p><p>Count the client-side instrumentation hits from your suspect:</p><pre><code>grep &quot;^47.128.44.19&quot; access.log | grep -E &quot;/api/|/track|/analytics|/beacon&quot; | wc -l
</code></pre><p>For a scraper, this comes back as <code>0</code>. A real browser session fires these constantly, so anything above zero, often well into the dozens per session, is a strong signal of real browser activity.</p><h3 id="sequential-systematic-url-patterns">Sequential / systematic URL patterns</h3><p>Humans browse associatively. They follow what interests them, jump around, double back, and so on. Scrapers walk in straight lines.</p><p>Look at a suspect&apos;s requests in time order:</p><pre><code>grep &quot;^47.128.44.19&quot; access.log | awk &apos;{print $4, $7}&apos; | head -12
</code></pre><p>The scraper sweeps through IDs in perfect order, about two per second, with no pauses:</p><pre><code>[10:42:01] /products/12841
[10:42:01] /products/12842
[10:42:02] /products/12843
[10:42:02] /products/12844
[10:42:03] /products/12845
[10:42:03] /products/12846
[10:42:04] /products/12847
[10:42:04] /products/12848
[10:42:05] /products/12849
[10:42:05] /products/12850
[10:42:06] /products/12851
[10:42:06] /products/12852
</code></pre><p>A real visitor&apos;s path looks nothing like that:</p><pre><code>[10:41:55] /
[10:42:03] /products/winter-jacket
[10:42:31] /products/winter-jacket?color=navy
[10:42:58] /cart
[10:43:12] /products/wool-scarf
[10:44:40] /products/winter-jacket
</code></pre><p>Homepage, a product, a variant of that product, a different product, the cart, and then back to the first one, with irregular gaps of eight to forty seconds where a person was actually reading.</p><h3 id="user-agent-distribution">User-agent distribution</h3><p>Now look at what everything is claiming to be:</p><pre><code>awk -F&apos;&quot;&apos; &apos;{print $6}&apos; access.log | sort | uniq -c | sort -rn | head -12
</code></pre><pre><code> 204417 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
 184992 python-requests/2.31.0
  61003 Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)
  44781 Mozilla/5.0 (compatible; ClaudeBot/1.0; +mailto:claudebot@anthropic.com)
  29550 Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)
  18204 Go-http-client/2.0
  12876 Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)
   9043 Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
   3001 Scrapy/2.11 (+https://scrapy.org)
   2422 curl/8.4.0
    880 (empty)
</code></pre><p>Reading down the list:</p><ul><li>The 204k &quot;Chrome&quot; entries look human, but this line almost certainly hides spoofed bots because impersonating Chrome is the easiest disguise there is.</li><li>184k <code>python-requests</code><strong>.</strong> That&apos;s a scripting library, and having a Python HTTP client as the second-busiest &quot;user&quot; is a lot.</li><li>GPTBot, ClaudeBot, and AhrefsBot: bots that honestly declare themselves. You can decide what to do with each by name.</li><li><code>Go-http-client</code>, <code>Scrapy</code>, <code>curl</code>, and the blank entry are almost all automation. A lot of curl requests could be individual people looking to pull something from the terminal.</li></ul><p>The honest bots label themselves, and the lazy scrapers don&apos;t bother to hide. The tricky ones might be hiding inside that first &quot;Chrome&quot; line, which is why user agent header is just one of the things to check.</p><h3 id="verifying-identity-with-a-dns-lookup">Verifying identity with a DNS lookup</h3><p>When a request claims to be a known crawler, you can verify its identity. The standard method is a reverse-then-forward DNS check.</p><p>For a request claiming to be Googlebot:</p><pre><code>$ host 66.249.66.1
1.66.249.66.in-addr.arpa domain name pointer crawl-66-249-66-1.googlebot.com.

$ host crawl-66-249-66-1.googlebot.com
crawl-66-249-66-1.googlebot.com has address 66.249.66.1
</code></pre><p>The IP reverse-resolves to a <code>googlebot.com</code> hostname, and that hostname forward-resolves back to the same IP. The round trip matches, and the domain is correct, so it&#x2019;s a verified Googlebot.</p><p>Now here&apos;s a request that also claimed to be Googlebot, from one of our scraper IPs:</p><pre><code>$ host 47.128.44.19
19.44.128.47.in-addr.arpa domain name pointer ec2-47-128-44-19.ap-southeast-1.compute.amazonaws.com.
</code></pre><p>It claimed to be Googlebot, but the IP reverse-resolves to an AWS EC2 instance in Singapore, not <code>googlebot.com</code>. Google does not crawl from EC2 instances. The user-agent was identical to the real Googlebot&apos;s, but it&#x2019;s not actually Google&#x2019;s.</p><h3 id="published-ip-ranges">Published IP ranges</h3><p>Most major crawlers publish the IP ranges they operate from, which gives you a faster check than a DNS round trip at scale. OpenAI publishes theirs as a JSON file:</p><pre><code>$ curl -s &lt;https://openai.com/gptbot.json&gt; | head
{
  &quot;creationTime&quot;: &quot;2025-10-30T11:00:00.000000&quot;,
  &quot;prefixes&quot;: [
    {
      &quot;ipv4Prefix&quot;: &quot;132.196.86.0/24&quot;
    },
    {
      &quot;ipv4Prefix&quot;: &quot;172.182.202.0/25&quot;
    },
    {
      &quot;ipv4Prefix&quot;: &quot;172.182.204.0/24&quot;
    ...
  ]
}
</code></pre><p>If a request claims to be GPTBot but its source IP isn&apos;t in OpenAI&apos;s published ranges, then it isn&apos;t GPTBot. The same approach works for Anthropic, Perplexity, Microsoft, and Google. The only maintenance cost is keeping the lists current, since they change.</p><h3 id="asn-lookup">ASN lookup</h3><p>Look at where traffic originates by network, not just by individual IP:</p><pre><code>awk &apos;{print $1}&apos; access.log | sort -u | asn-lookup | sort | uniq -c | sort -rn | head
</code></pre><pre><code>  189442  AS16509  Amazon-AES
   72103  AS14061  DigitalOcean
   41996  AS24940  Hetzner
   38201  AS5089   Virgin Media
   31774  AS2856   BT
   22018  AS5607   Sky UK
</code></pre><p>The top three sources by volume are all data centers while real consumer audiences come from residential and mobile ISPs like Virgin, BT, and Sky. So if you are seeing the most requests from AWS and Hetzner, these are likely not real users. One important thing to mention is that legitimate crawlers like Googlebot also run from data centers, so this signal isn&apos;t conclusive on its own. However, you can identify legit crawlers in different ways.</p><h3 id="geographic-and-timing-anomalies">Geographic and timing anomalies</h3><p>Requests by country:</p><pre><code>  142883  SG
   38201  GB
   12009  US
    8841  DE
</code></pre><p>This one depends entirely on your situation, but the logic is simple: if you&apos;re a UK e-commerce store and your single largest source of traffic is Singapore, by a wide margin, you&apos;re almost certainly looking at bots rather than a sudden surge of overseas customers.</p><p>Timing tells the same kind of story. Here&apos;s a suspect&apos;s requests per hour across a day:</p><pre><code>00:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  5,012
01:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  4,998
02:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  5,031
03:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  4,987
...
13:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  5,004
...
23:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  5,019
</code></pre><p>An almost flat line with roughly 5,000 requests an hour at 3 a.m. and 1 p.m. alike. Human behavior has some daily rhythm that makes traffic quieter overnight, build through the morning, and reach its peak in the afternoon and evening. A perfectly flat 24-hour line is a machine crawling at a constant rate. Real human traffic would look more like this:</p><pre><code>00:00  &#x2588;&#x2588;&#x2588;&#x2588;  890
03:00  &#x2588;&#x2588;  410
08:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  3,100
13:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  5,200
20:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  4,600
23:00  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  2,000
</code></pre><h2 id="the-issue-with-this-approach">The issue with this approach</h2><p>Every check above happened after the fact, on traffic you&apos;ve already paid for. The scraper in these examples may have run 200,000+ requests before you grepped your way to it. IPs rotate, published ranges change, new crawlers appear, and the ones trying to hide adapt to whatever you&apos;re filtering on. So it&#x2019;s a lot of work, and most of it is reactive.</p><p>So the real goal here is to understand your traffic well enough to make deliberate calls: which bots you want, which you don&apos;t, and where to draw the line. Once you can see clearly what&apos;s hitting your site, you&apos;re in a position to do something about it.</p>]]></content:encoded></item><item><title><![CDATA[Build and deploy scripts at the edge with the bunny.net CLI]]></title><description><![CDATA[You can now write JS/TS scripts, develop and test them locally, deploy to our global network, manage environment variables, and step through deployment history, all from your terminal.]]></description><link>https://bunny.net/blog/build-and-deploy-scripts-at-the-edge-with-the-bunny-net-cli/</link><guid isPermaLink="false">6a291e8b160dc403fbfcf427</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Jamie Barton]]></dc:creator><pubDate>Wed, 10 Jun 2026 08:22:55 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/06/deploy-scripts-at-the-edge-with-the-bunny.net-CLI.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/06/deploy-scripts-at-the-edge-with-the-bunny.net-CLI.png" alt="Build and deploy scripts at the edge with the bunny.net CLI"><p>A few weeks ago we <a href="https://bunny.net/blog/introducing-the-bunny-net-cli/">introduced the bunny.net CLI</a>, one command-line tool for managing your entire bunny.net stack. That first release shipped with full Database support, and we promised more would follow. </p><p>Today, <a href="https://docs.bunny.net/scripting"><strong>Edge Scripting</strong></a> lands in the CLI.</p><p>You can scaffold a new script from a template, develop and test it locally with Node or Deno, deploy a built bundle to the global network, manage environment variables, and step through deployment history from your terminal.</p><h2 id="a-two-minute-tour">A two-minute tour</h2><p>If you&apos;ve already got the CLI installed and you&apos;re logged in, three commands will get you to a deployed function:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
bunny scripts init --name my-edge-app --type standalone --template <span style="color:#BDE052;">&quot;Return JSON&quot;</span> --deploy
<span style="color:#ECEC93;">cd</span> my-edge-app
<span style="color:#ECEC93;">npm</span> run build
bunny scripts deploy dist/index.js
</pre>
<br>
<!--kg-card-end: html-->
<p>If you haven&apos;t installed the CLI yet, do that first:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
<span style="color:#ECEC93;">npm install</span> -g @bunny.net/cli
bunny login
</pre>
<br>
<!--kg-card-end: html-->
<h2 id="scaffold-a-project-from-a-template">Scaffold a project from a template</h2>
<!--kg-card-begin: html-->
<p>
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts init</code> creates a new edge script project on your machine. Run it with no flags and the CLI walks you through it interactively, asking you to pick a template, name the project, and choose whether to install dependencies:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts init

</div>
<br>
<!--kg-card-end: html-->
<p>You can also pass everything as flags, which is handy when you&apos;re in a hurry or when an agent is driving:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
bunny scripts init \
  --name my-edge-app \
  --type standalone \
  --template <span style="color:#BDE052;">&quot;Return JSON&quot;</span>
</pre>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Add <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--deploy</code> to also create the remote script on bunny.net and link the directory in the same step. Without it, <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">init</code> only scaffolds the local project, and you create the remote script later with <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts create</code>.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  The built-in templates are <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">Empty</code>, <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">Return JSON</code>, and <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">Simple Middleware</code>. Each one comes with a working project layout and sensible defaults so you can build and deploy straight away.
</p>
<!--kg-card-end: html-->
<p>You can also bring your own template from a Git repo:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts init --name my-edge-app --repo user/my-template

</div>
<br>
<!--kg-card-end: html-->
<p>Custom templates always prompt before installing dependencies, since a template&apos;s install scripts run on your machine. Review the repo before you let it install.</p><h2 id="run-it-locally">Run it locally</h2><p>Edge Scripts are JavaScript and TypeScript projects built on Web-standard APIs. You develop them locally with your own editor and the debugging tools you already use.<br><br>The CLI auto-detects your package manager from the template&apos;s lockfile (bun, npm, pnpm, or yarn) and uses the template&apos;s own dev and build scripts:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
<span style="color:#ECEC93;">cd</span> my-edge-app
<span style="color:#ECEC93;">npm install</span>
<span style="color:#ECEC93;">npm</span> run dev
</pre>
<!--kg-card-end: html-->
<p>The CLI doesn&apos;t yet ship a custom runtime emulator. It wraps the tools you&apos;d reach for anyway, so local development stays fast and predictable.</p><h2 id="link-a-directory-to-a-script">Link a directory to a script</h2><p>Once you&apos;ve got something you want to deploy, connect the working directory to a script on bunny.net:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts <span style="color:#ECEC93;">link</span>
</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Run it once, and it writes a small <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.bunny/script.json</code> manifest into your project that records which script on bunny.net this directory belongs to. From that point on, every other <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">scripts</code> command knows what you&apos;re targeting:
</p>

<ul>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts deploy</code> deploys this project</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts env set API_KEY=...</code> writes to this script&apos;s environment</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts deployments list</code> shows this script&apos;s history</li>
</ul>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  The script ID stays out of your shell history, and you don&apos;t pass it on every command. Run <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts link</code> with no flags to choose from a list, or <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts link --id 12345</code> to link directly.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  The manifest holds per-developer state, so it shouldn&apos;t be committed. <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts init</code> adds <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.bunny/</code> to your <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.gitignore</code> automatically. If you ran <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts link</code> or <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts create</code> in an existing repo, add <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.bunny/</code> to <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.gitignore</code> yourself, then anyone who clones the repo links their own script.
</p>
<!--kg-card-end: html-->
<p>For those deploying scripts in CI, you can skip the project linking by passing the Edge Script ID when you deploy:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts deploy dist/index.js <span style="color:#D1949E;">12345</span>
</div>
<!--kg-card-end: html-->
<h2 id="build-and-deploy">Build and deploy</h2>
<!--kg-card-begin: html-->
<p>
  Edge Scripts deploy as a single built bundle. The CLI doesn&apos;t build for you, so run the template&apos;s own build step first, then point <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">deploy</code> at the output:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
<span style="color:#ECEC93;">npm</span> run build
bunny scripts deploy dist/index.js
</pre>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">deploy</code> uploads the file as a new release and publishes it live by default. On success it prints the script&apos;s live hostname:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
&#x2713; Published my-edge-app
  https://my-edge-app.bunny.run
</pre>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  If you want to upload a release without publishing it, stage it with <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--skip-publish</code> and publish later.
</p>
<!--kg-card-end: html-->
<h2 id="put-it-on-your-own-domain">Put it on your own domain</h2>
<!--kg-card-begin: html-->
<p>
  Every script gets a <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny.run</code> URL, but you can also bring your own domain. We&apos;ve made it super easy with a new command:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts domains <span style="color:#ECEC93;">add</span> shop.example.com

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts domains add</code> sets up HTTPS on your own domain. bunny.net issues SSL certificates for free and can do so as soon as your DNS points to the network, so the command prints the exact CNAME record to create, along with the follow-up command to run. Once DNS has propagated, request your certificate:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts domains ssl shop.example.com

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  HTTPS is enforced by default, so anyone arriving over <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">http://</code> is redirected.
</p>
<!--kg-card-end: html-->
<p>List a script&apos;s domains any time to check their SSL status:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts domains list

</div>
<!--kg-card-end: html-->
<h2 id="manage-environment-variables-and-secrets">Manage environment variables and secrets</h2><p>Most edge functions need configuration values like API tokens, feature flags, or region hints. The CLI handles two kinds. Plain <strong>variables</strong> are stored on bunny.net and can be read back. <strong>Secrets</strong> are encrypted, and their values can never be read back once set:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
bunny scripts <span style="color:#ECEC93;">env set</span> DATABASE_URL <span style="color:#BDE052;">&quot;libsql://...&quot;</span>
bunny scripts <span style="color:#ECEC93;">env set</span> API_KEY <span style="color:#BDE052;">&quot;sk_...&quot;</span> --secret
bunny scripts <span style="color:#ECEC93;">env</span> list
bunny scripts <span style="color:#ECEC93;">env</span> remove API_KEY
</pre>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Names are uppercased automatically, and a name can be a variable or a secret but not both. <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">env list</code> shows secret values as blank, since the API never returns them.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  To pull plain variables into a local file for development, use <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">env pull</code>. It writes <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">NAME=VALUE</code> lines to <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.bunny/.env</code>. Secrets are never included, so keep their source values somewhere safe:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts <span style="color:#ECEC93;">env</span> pull

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Listing supports <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--output json</code>, useful for diffing or syncing with another tool:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts <span style="color:#ECEC93;">env</span> list --output json | jq <span style="color:#BDE052;">&apos;.[].name&apos;</span>
</div>
<!--kg-card-end: html-->
<h2 id="deployment-history">Deployment history</h2><p>Every release is recorded, and the CLI gives you a view into that history:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts deployments list

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
<span style="color:#D1949E;">ID</span>       Status      Author   Released   Published
<span style="color:#D1949E;">14021</span>    &#x25CF; Live      jamie    2m ago     yes
<span style="color:#D1949E;">14008</span>    &#x25CB; Archived  jamie    1h ago     yes
<span style="color:#D1949E;">13990</span>    &#x25CB; Archived  jamie    1d ago     yes
</pre>
<!--kg-card-end: html-->
<p>The live release is marked with a filled dot, and archived releases are marked with an open one. If a release is live and the script has a linked pull zone, the hostname is printed at the end.<br><br>Need to roll back? You don&apos;t have to rebuild anything. The bundle for every past release is already on bunny.net, so grab the ID of a known-good one from that list and republish it:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny scripts deployments publish <span style="color:#D1949E;">13990</span>
</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  This flips which release is live without re-uploading anything, so a bad deploy can be undone in seconds. <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">deploy</code> ships new code. <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">publish</code> re-promotes a release that has already shipped.
</p>
<!--kg-card-end: html-->
<h2 id="see-how-its-doing">See how it&apos;s doing</h2>
<!--kg-card-begin: html-->
<p>
  Once a script is serving traffic, you&apos;ll want to know how much traffic it&apos;s handling and what it&apos;s costing. <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts stats</code> pulls request, CPU, and cost totals for the script and draws a per-day bar chart of requests served right in your terminal:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
bunny scripts stats

Key                   Value
Script                bunny-cards
Period                last <span style="color:#D1949E;">30</span> days
Total Requests        <span style="color:#D1949E;">1,455</span>
Total CPU             <span style="color:#D1949E;">43,336</span>ms
Avg CPU / Execution   <span style="color:#D1949E;">29.78</span>ms
Total Cost            <span style="color:#ECEC93;">$0.00</span>

Requests served
May <span style="color:#D1949E;">18</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
May <span style="color:#D1949E;">19</span>, <span style="color:#D1949E;">2026</span>  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;  <span style="color:#D1949E;">504</span>
May <span style="color:#D1949E;">20</span>, <span style="color:#D1949E;">2026</span>  &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;  <span style="color:#D1949E;">383</span>
May <span style="color:#D1949E;">21</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
May <span style="color:#D1949E;">22</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
May <span style="color:#D1949E;">23</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
May <span style="color:#D1949E;">24</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
May <span style="color:#D1949E;">25</span>, <span style="color:#D1949E;">2026</span>  &#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">4</span>
May <span style="color:#D1949E;">26</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
May <span style="color:#D1949E;">27</span>, <span style="color:#D1949E;">2026</span>  &#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">5</span>
May <span style="color:#D1949E;">28</span>, <span style="color:#D1949E;">2026</span>  &#x2588;&#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;   <span style="color:#D1949E;">37</span>
May <span style="color:#D1949E;">29</span>, <span style="color:#D1949E;">2026</span>  &#x2588;&#x2588;&#x2588;&#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;   <span style="color:#D1949E;">90</span>
May <span style="color:#D1949E;">30</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
May <span style="color:#D1949E;">31</span>, <span style="color:#D1949E;">2026</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
Jun <span style="color:#D1949E;">1</span>, <span style="color:#D1949E;">2026</span>   &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
Jun <span style="color:#D1949E;">2</span>, <span style="color:#D1949E;">2026</span>   &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
Jun <span style="color:#D1949E;">3</span>, <span style="color:#D1949E;">2026</span>   &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">0</span>
Jun <span style="color:#D1949E;">4</span>, <span style="color:#D1949E;">2026</span>   &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;  <span style="color:#D1949E;">223</span>
Jun <span style="color:#D1949E;">5</span>, <span style="color:#D1949E;">2026</span>   &#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;  <span style="color:#D1949E;">191</span>
Jun <span style="color:#D1949E;">6</span>, <span style="color:#D1949E;">2026</span>   &#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;   <span style="color:#D1949E;">11</span>
Jun <span style="color:#D1949E;">7</span>, <span style="color:#D1949E;">2026</span>   &#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">5</span>
Jun <span style="color:#D1949E;">8</span>, <span style="color:#D1949E;">2026</span>   &#x2588;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;    <span style="color:#D1949E;">2</span>
</pre>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  By default, it covers the last 30 days. Narrow the window with <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--from</code> and <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--to</code>, or switch to an hourly breakdown when you&apos;re chasing a spike:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
bunny scripts stats --from 2026-05-01 --to 2026-05-31
bunny scripts stats --hourly
</pre>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Like everything else, it uses your linked script when you don&apos;t pass an ID, and <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--output json</code> gives you the data to pipe into whatever you use to track usage.
</p>
<!--kg-card-end: html-->
<h2 id="works-with-your-agent-and-ci">Works with your agent and CI</h2>
<!--kg-card-begin: html-->
<p>
  Prompts and spinners are TTY-aware, so they disappear in CI. Commands support <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--output json</code>, and destructive commands like <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">delete</code> and <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">env remove</code> take <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--force</code> so they don&apos;t block on a confirmation prompt in a pipeline.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  In CI you can skip the global install and run the CLI through <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">npx</code>:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
# .github/workflows/deploy.yml
- <span style="color:#ECEC93;">run:</span> npm ci
- <span style="color:#ECEC93;">run:</span> npm run build
- <span style="color:#ECEC93;">run:</span> npx @bunny.net/cli scripts deploy dist/index.js ${{ secrets.SCRIPT_ID }}
  <span style="color:#ECEC93;">env:</span>
    <span style="color:#ECEC93;">BUNNYNET_API_KEY:</span> ${{ secrets.BUNNYNET_API_KEY }}
</pre>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  When you scaffold with <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny scripts init --deploy</code>, the command prints the <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">SCRIPT_ID</code> to add as a repo secret, so CI deploys to the right script without committing the link manifest.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  AI coding assistants use the CLI directly, either from a global install or through <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">npx @bunny.net/cli</code>. We ship Edge Scripting skills alongside it, so Claude Code, Cursor, Windsurf, or any agent that runs shell commands can scaffold a project, link it, set environment variables, and deploy.
</p>
<!--kg-card-end: html-->
<h2 id="whats-next">What&apos;s next</h2><p>Edge Scripting joins Bunny Database in the CLI today. Storage, Magic Containers, and the rest of the bunny.net stack are coming next.</p><p>Get started and create your first Edge Script with the bunny.net CLI:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">
<span style="color:#ECEC93;">npm install</span> -g @bunny.net/cli
bunny login
bunny scripts init
</pre>
<!--kg-card-end: html-->
<p>The CLI is open source and developed in public. If something&apos;s broken, <a href="https://github.com/BunnyWay/cli/issues" rel="noopener noreferrer">open an issue</a>. If you want to share what you&apos;ve built, find us in <a href="https://discord.com/invite/bunnynet" rel="noopener noreferrer">Discord</a>.</p>]]></content:encoded></item><item><title><![CDATA[Built on bunny.net - May 2026]]></title><description><![CDATA[Here's the first edition of a monthly roundup highlighting the projects, packages, and writeups we've spotted across the community]]></description><link>https://bunny.net/blog/built-on-bunny-net-may-2026/</link><guid isPermaLink="false">6a197100160dc403fbfcf3ac</guid><category><![CDATA[Tips and Tricks]]></category><dc:creator><![CDATA[Dino Kukic]]></dc:creator><pubDate>Fri, 29 May 2026 11:10:47 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/05/Built-on-bunny-May-1.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/05/Built-on-bunny-May-1.png" alt="Built on bunny.net - May 2026"><p>We pay attention to what people build on bunny.net. Some of it genuinely impresses us, and all of it deserves a shoutout. So, here&apos;s the first edition of a monthly roundup highlighting the projects, packages, and writeups we&apos;ve spotted across the community. If you&apos;re planning your own setup, there&#x2019;s plenty here worth exploring. It&#x2019;s also our way of saying thanks to the people building with bunny.net.</p><p>If you&apos;ve shipped, written, or open-sourced something, tag us or send us a message. We&apos;d love to take a look for next month&apos;s roundup.</p><pre><code class="language-python">The community packages below are built by people working 
with bunny.net, but they aren&apos;t officially maintained by us.</code></pre><h2 id="shipped-on-bunnynet">Shipped on bunny.net</h2><h3 id="kakuso"><a href="https://kaku.so/">kaku.so</a></h3><p>A statically rendered Japanese dictionary with 290k entries translated into 11 languages, plus built-in spaced repetition for practice. The scale of the project is genuinely impressive: more than 10 million files, 400 GB+ of data, and a 12-hour build process. The entire stack moved from Azure PostgreSQL to bunny.net after <a href="https://bunny.net/database/">Bunny Database</a> launched.</p><h3 id="yaanch"><a href="https://yaan.ch/">yaan.ch</a></h3><p>A drop-in replacement for hCaptcha, reCAPTCHA and Friendly Captcha. Invisible, GDPR-compliant, not proof-of-work. The marketing pages and backend run on <a href="https://bunny.net/storage/">Bunny Storage</a> and <a href="https://bunny.net/edge-scripting/">Edge Scripting</a>. The Rust anti-bot engine runs on <a href="https://bunny.net/magic-containers/">Magic Containers</a>, and the database will move to Bunny DB once it exits public preview.</p><h3 id="convex-self-hosted-on-magic-containers">Convex self-hosted on Magic Containers</h3><p><a href="https://www.linkedin.com/in/lipaonline/">Patrick Faust</a> got the Convex self-hosted runtime running on bunny.net Magic Containers and documented the process in two LinkedIn posts: <a href="https://www.linkedin.com/posts/lipaonline_big-milestone-unlocked-convex-just-approved-activity-7443538966836035584-C0h8/">one when it was approved</a>, and <a href="https://www.linkedin.com/posts/lipaonline_convex-self-hosted-is-live-on-bunny-magic-activity-7449773848343846913-Wn6H/">another when it went live</a>.</p><h3 id="nitrofilm"><a href="https://nitro.film/">Nitro.film</a></h3><p>Apple TV app built by a solo founder on <a href="https://bunny.net/stream/">Bunny Stream</a>, with much of the development process <a href="https://www.linkedin.com/posts/harrylang_buildinpublic-solofounder-claudecode-activity-7450683849157795841-ozKS">shared publicly on LinkedIn</a> as part of a &#x2018;build in public journey&#x2019;.</p><h3 id="kraken">Kraken</h3><p><a href="https://www.linkedin.com/in/robin-mb/" rel="noreferrer">Robin Dost</a> has been building <a href="https://kraken.malwarebox.eu/">Kraken</a>, a CTI platform that tracks adversary infrastructure, including domains, IPs, and dead drops, over time so analysts don&#x2019;t lose visibility after initial discovery. It&#x2019;s currently running live in evaluation and hosted in Europe on bunny.net.</p><h3 id="the-imago-platform">The Imago Platform</h3><p><a href="https://www.linkedin.com/in/helander/">Magnus Helander</a> is building the <a href="https://www.imagoplatform.ai/">Imago Platform</a> on <a href="https://bunny.net/cdn/">Bunny CDN</a>, sitting at <a href="https://www.linkedin.com/posts/helander_buildingthe-imago-platformwe-could-have-activity-7450298367915618304-hlKR/">99.995% uptime since launch</a>. He also built and shipped a Claude skill for writing Edge Scripts on bunny.net.</p><h3 id="moving-to-bunnynet">Moving to bunny.net</h3><p>Three writeups from the past month, all worth reading if you&apos;re thinking about your own setup.</p><ul><li><strong>Johanna Larsson</strong> - <a href="https://jola.dev/posts/dropping-cloudflare" rel="noreferrer">moved a Phoenix/Elixir blog to bunny.net</a> with a full walkthrough and code examples.</li><li><strong>Alec Armbruster</strong> - built a <a href="https://alec.is/posts/cloudflare-tunnels-on-bunny-net/">tunnels-style setup running on bunny.net</a>.</li><li><strong>David Drugeon-Hamon</strong> - documented a <a href="https://david.drugeon-hamon.bzh/blog/2026/04/migration-github-codeberg/">GitHub-to-Codeberg switch with bunny.net in the stack</a>.</li></ul><h2 id="shipped-for-bunnynet">Shipped for bunny.net</h2><h3 id="cdn"><strong>CDN</strong></h3><p>A community-built API client I recently came across:</p><ul><li><a href="https://pypi.org/project/bunny-cdn-sdk/"><code>bunny-cdn-sdk</code></a> - typed Python SDK and CLI covering Bunny CDN and Bunny Storage.</li></ul><h3 id="stream-and-video">Stream and video</h3><ul><li><a href="https://www.npmjs.com/package/bunnycdn-stream"><code>bunnycdn-stream</code></a> - TypeScript library for the Bunny Stream API.</li><li><a href="https://www.npmjs.com/package/playstack"><code>playstack</code></a> - React video player that supports Bunny Stream.</li><li><a href="https://www.npmjs.com/package/@cliff-studio/sanity-plugin-bunny-input"><code>sanity-plugin-bunny-input</code></a> - input component for Sanity Studio.</li></ul><h3 id="storage">Storage</h3><ul><li><a href="https://www.npmjs.com/package/@seshuk/payload-storage-bunny"><code>payload-storage-bunny</code></a> - Payload CMS adapter for Bunny Storage and Bunny Stream with auto-purging and resumable uploads via tus.</li><li><a href="https://www.npmjs.com/package/bunny-transfer"><code>bunny-transfer</code></a> - rsync-style CLI for moving files in and out of storage zones.</li><li><a href="https://www.npmjs.com/package/upload-to-bunny"><code>upload-to-bunny</code></a> - directory uploader aimed at CI pipelines.</li></ul><h3 id="shield-and-security">Shield and security</h3><ul><li><a href="https://pypi.org/project/octorules-bunny/"><code>octorules-bunny</code></a> - <a href="https://bunny.net/shield/">Shield</a> WAF rules as code, built and used in production by Doctena, an EU healthcare scheduling company.</li></ul><h3 id="working-with-ai-agents">Working with AI agents</h3><ul><li><a href="https://www.npmjs.com/package/bunnycdn-mcp"><code>bunnycdn-mcp</code></a> - MCP server for bunny.net.</li><li><code>bunny-edge</code> Claude plugin - Magnus Helander&#x2019;s Claude skill for writing Edge Scripts on bunny.net. Install with <code>/plugin install bunny-edge@mheland</code>.</li></ul><p>That&#x2019;s it for May. We&#x2019;ll be back next month with more projects, tools, and experiments from across the bunny.net community. If you&#x2019;d like to be featured, tag us on social media or share your project in our Discord community. Join our <a href="https://discord.com/invite/bunnynet?ref=bunny.net">Discord here</a>.</p>]]></content:encoded></item><item><title><![CDATA[How we sped up deploys, updates, and undeploys in Magic Containers]]></title><description><![CDATA[We reduced deploy and update latency from tens of seconds to under five by combining event-driven acceleration with control-loop reliability.]]></description><link>https://bunny.net/blog/how-we-sped-up-deploys-updates-and-undeploys-in-magic-containers/</link><guid isPermaLink="false">6a182c5d160dc403fbfcf38c</guid><category><![CDATA[Compute]]></category><dc:creator><![CDATA[Anton Zvonko Gazvoda]]></dc:creator><pubDate>Thu, 28 May 2026 12:02:48 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/05/Faster-Deploys--Undeploys--and-Updates-for-Magic-Containers.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/05/Faster-Deploys--Undeploys--and-Updates-for-Magic-Containers.png" alt="How we sped up deploys, updates, and undeploys in Magic Containers"><p>Magic Containers became programmable with the introduction of the Public API. The next step was making it react in real time.</p><p>In practice, operations like deploys and updates could take tens of seconds to complete. Not because anything was failing, but because of how control loops work.</p><p>Each part of the system waits for the next reconciliation cycle, and those delays stack up across components.</p><p>As workloads become more short-lived and automated, lifecycle speed becomes an increasingly important part of the developer experience. Spinning up a container, scaling it, or tearing it down should feel immediate.</p><p>This is the problem we set out to solve.</p><h2 id="the-problem-control-loops-introduce-latency">The problem: control loops introduce latency</h2><p>Magic Containers is built around a&#xA0;<strong>control loop architecture</strong>.</p><p>Each component continuously reconciles&#xA0;<em>desired state &#x2192; actual state</em>:</p><ul><li><strong>Application Provisioner</strong>&#xA0;&#x2192; selects regions for deployment</li><li><strong>Controller Manager</strong>&#xA0;&#x2192; ensures the desired number of replicas</li><li><strong>Scheduler</strong>&#xA0;&#x2192; assigns pods to nodes</li><li><strong>Local Container Manager (LCM)</strong>&#xA0;&#x2192; ensures containers run on the assigned node</li></ul><p>Each of these components runs independently in a loop:</p><pre><code class="language-python">while&#xA0;True:
	observe_state()
  diff&#xA0;=&#xA0;desired_state&#xA0;-&#xA0;actual_state
  if&#xA0;diff:
	  reconcile(diff)
  sleep(interval)
</code></pre><p>This model is extremely reliable and forms the backbone of many distributed systems, including Kubernetes.</p><p>But it comes with a tradeoff.</p><p><strong>Latency compounds across loops.</strong></p><p>Each loop runs on an interval of&#xA0;<strong>~5 to 10 seconds</strong>.</p><p>That means a single operation doesn&#x2019;t execute immediately. Instead, it waits for the next loop iteration.</p><p>Now chain multiple components together:</p><pre><code class="language-python">User action
	&#x2192; waits for Provisioner loop (up to 10s)
	&#x2192; waits for Controller loop (up to 10s)
	&#x2192; waits for Scheduler loop (up to 10s)
  &#x2192; waits for LCM loop (up to 10s)
</code></pre><p>In the worst case, this stacks up to&#xA0;<strong>tens of seconds before a container is fully running</strong>.</p><p>Even under typical conditions, deploys and updates felt noticeably delayed.</p><p>Nothing was technically incorrect. The system always converged to the correct state, but the&#xA0;<em>experience</em>&#xA0;lagged behind what modern workflows expect.</p><h3 id="what-we-considered-and-rejected">What we considered (and rejected)</h3><p>We explored several approaches before settling on the final solution.</p><p><strong>1. Decreasing loop intervals</strong></p><p>Reducing intervals from ~10 seconds to sub-second.</p><p><strong>Why we rejected it:</strong></p><ul><li>Significant increase in CPU usage across all control plane components</li><li>Higher pressure on state storage and coordination systems</li><li>Still fundamentally polling-based, meaning latency never reaches zero</li></ul><p><strong>2. Removing loops entirely</strong></p><p>Moving to a purely event-driven system.</p><p><strong>Why we rejected it:</strong></p><ul><li>Loops are critical as a&#xA0;<strong>safety mechanism</strong></li><li>They continuously verify and correct drift between desired and actual state</li><li>Without them, missed events could lead to permanent inconsistencies</li></ul><p><strong>3. Hybrid model (chosen)</strong></p><p>Keep loops for correctness and safety, but introduce&#xA0;<strong>events for immediacy</strong>.</p><h2 id="the-solution-event-driven-acceleration">The solution: event-driven acceleration</h2><p>We introduced a&#xA0;<strong>message broker with event queues</strong>&#xA0;between components.</p><p>The key idea: <em>Loops ensure consistency. Events provide speed.</em></p><p>Instead of waiting for the next loop iteration, components now react immediately when something changes.</p><p><strong>Before: loop-driven propagation</strong></p><pre><code class="language-python">[User Action]
&#x2193;
(wait for Provisioner loop)
&#x2193;
(wait for Controller loop)
&#x2193;
(wait for Scheduler loop)
&#x2193;
(wait for LCM loop)
&#x2193;
[Container Running]
</code></pre><p><strong>After: event-accelerated flow</strong></p><pre><code class="language-python">[User Action]
&#x2193;
[Event: Application Created] &#x2192; Queue
&#x2193;
[Provisioner triggered immediately]
&#x2193;
[Event: Provisioning Complete]
&#x2193;
[Controller Manager triggered]
&#x2193;
[Event: Pods Created]
&#x2193;
[Scheduler triggered]
&#x2193;
[Event: Pod Scheduled]
&#x2193;
[LCM triggered]
&#x2193;
[Container Running]
</code></pre><h3 id="how-it-works">How It Works</h3><p>Each component now listens for specific events and reacts instantly.</p><p><strong>Example event</strong></p><pre><code class="language-json">{
    &quot;type&quot;: &quot;application.created&quot;,
    &quot;app_id&quot;: &quot;app_123&quot;,
    &quot;regions&quot;: [&quot;eu-central&quot;, &quot;us-east&quot;],
    &quot;timestamp&quot;: 1713949200
}
</code></pre><p><strong>Provisioner</strong></p><pre><code class="language-go">func handleApplicationCreated(event Event) {
    regions := selectRegions(event)
    publish(&quot;provisioning.completed&quot;, regions)
}
</code></pre><p><strong>Controller Manager</strong></p><pre><code class="language-go">func handleProvisioningComplete(event Event) {
    createReplicas(event.app_id, desiredReplicas)
    publish(&quot;replicas.created&quot;, event.app_id)
}
</code></pre><p><strong>Scheduler</strong></p><pre><code class="language-go">func handleReplicasCreated(event Event) {
    node := selectNode(event)
    publish(&quot;pod.scheduled&quot;, node)
}
</code></pre><p><strong>Local Container Manager (LCM)</strong></p><pre><code class="language-go">func&#xA0;handlePodScheduled(event&#xA0;Event) {  
		startContainer(event.node,&#xA0;event.pod)
}
</code></pre><h3 id="important-loops-still-exist">Important: loops still exist</h3><p>The control loops were&#xA0;<strong>not removed</strong>.</p><p>They still run continuously to:</p><ul><li>Detect drift (e.g., crashed containers or missing replicas)</li><li>Reconcile inconsistencies</li><li>Act as a fallback if events are delayed or lost</li></ul><pre><code class="language-go">// simplified reconciliation loop
for {
    diff := computeDiff(desiredState, actualState)
    if diff != nil {
        reconcile(diff)
    }
    sleep(5 * time.Second)
}
</code></pre><p>This hybrid design gives us:</p><ul><li><strong>Fast reaction time (events)</strong></li><li><strong>Strong consistency guarantees (loops)</strong></li></ul><p>These events don&#x2019;t just drive internal components, they also power real-time updates across the platform, including the Dashboard.</p><h2 id="from-control-plane-to-user-experience">From control plane to user experience</h2><p>Reducing backend latency is only part of the story.</p><p>Before this change, even when operations completed, the <strong>Dashboard still relied on polling</strong>&#xA0;to fetch updates. This introduced an additional delay between something happening in the system and the user actually seeing it.</p><p>In practice, this meant:</p><ul><li>Deploy finishes &#x2192; UI updates a few seconds later</li><li>Scaling event happens &#x2192; user sees it after the next refresh cycle</li></ul><p>To solve this, we extended the same event-driven model all the way to the frontend.</p><h3 id="real-time-updates-via-websockets">Real-time updates via WebSockets</h3><p>We introduced&#xA0;<strong>WebSocket-based event streaming</strong>&#xA0;between the control plane and the Dashboard.</p><p>Instead of polling for state changes, the UI now subscribes to live updates:</p><pre><code class="language-python">Client &#x2192; opens WebSocket connection
&#x2192; subscribes to application events
</code></pre><p>Whenever something changes:</p><pre><code class="language-python">[Control Plane Event]
&#x2193;
[Message Broker]
&#x2193;
[WebSocket Gateway]
&#x2193;
[Dashboard UI updates instantly]
</code></pre><h3 id="what-this-changes">What this changes</h3><p>This removes the final layer of perceived latency.</p><h4 id="before">Before</h4><ul><li>Backend finishes &#x2192; UI polls &#x2192; user sees update later</li></ul><h4 id="after">After</h4><ul><li>Backend finishes &#x2192; event emitted &#x2192; UI updates instantly</li></ul><h4 id="result">Result</h4><ul><li>Deploy progress updates feel&#xA0;<strong>real time</strong></li><li>Scaling actions are visible&#xA0;<strong>immediately</strong></li><li>State transitions (creating &#x2192; running &#x2192; scaling) feel&#xA0;<strong>continuous</strong></li></ul><h3 id="why-this-matters">Why this matters</h3><p>Without this step, the platform would be technically fast but still&#xA0;<em>feel slow</em>.</p><p>By pushing events all the way to the UI, we aligned:</p><ul><li><strong>System speed</strong></li><li><strong>User perception</strong></li></ul><h2 id="the-impact">The impact</h2><p>By eliminating waiting between steps, we removed the largest source of latency.</p><p><strong>Before vs. after</strong></p>
<!--kg-card-begin: html-->
<table style="border:1px solid rgba(24, 61, 109, 0.18); border-collapse:collapse; width:100%;"> <thead> <tr style="font-weight:700; text-align:left; background:#223c6a; color:white;"> <th style="padding:12px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;"> Operation </th> <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;"> Before (loop-driven) </th> <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); color:white;"> After (event-driven) </th> </tr> </thead> <tbody> <tr> <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Deploy</td> <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">10&#x2013;40s</td> <td style="padding:12px 14px; vertical-align:middle; color:#183d6d;"><strong>&lt; 5s</strong></td> </tr> <tr> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Update</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">10&#x2013;40s</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;"><strong>&lt; 4s</strong></td> </tr> <tr> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Undeploy</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">~60s+</td> <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;"><strong>~60s (grace period)</strong></td> </tr> </tbody> </table> <br>
<!--kg-card-end: html-->
<h3 id="what-changed-technically">What changed technically</h3><ul><li>Removed dependency on loop timing for forward progress</li><li>Reduced end-to-end latency by&#xA0;<strong>an order of magnitude</strong></li><li>Maintained correctness via continuous reconciliation</li></ul><h3 id="why-this-matters-1">Why this matters</h3><p>This fundamentally changes how Magic Containers behaves.</p><ul><li><strong>CI/CD pipelines speed up.</strong> Infrastructure is no longer the slowest step</li><li><strong>Ephemeral workloads become practical.</strong> Create &#x2192; run &#x2192; destroy flows now complete in seconds</li><li><strong>Event-driven systems feel natural.</strong> Infrastructure now reacts at the same speed as application logic</li></ul><h3 id="tradeoffs-and-challenges">Tradeoffs and challenges</h3><p><strong>1. Event ordering</strong></p><p>Ensuring correct sequencing across distributed components.</p><p><strong>Solution:</strong></p><ul><li>Idempotent handlers</li></ul><p><strong>2. Reliability</strong></p><p>Events can fail or be delayed.</p><p><strong>Solution:</strong></p><ul><li>Retry mechanisms</li><li>Dead-letter queues</li><li>Control loops as fallback</li></ul><p><strong>3. Observability</strong></p><p>Async systems are harder to debug.</p><p><strong>Solution:</strong></p><ul><li>Correlation IDs</li><li>Event tracing across components</li></ul><h2 id="what%E2%80%99s-next">What&#x2019;s next</h2><p>We&#x2019;re already exploring:</p><ul><li>Optimizing image download times to reduce startup time</li><li>Automated build and updates directly from your GitHub repository</li><li>Access to recent log history alongside live logs for easier troubleshooting</li></ul><h2 id="final-thoughts">Final thoughts</h2><p>Magic Containers started as a loop-driven control plane designed for correctness.</p><p>With the introduction of event-driven acceleration, it now reacts immediately to changes, without relying on the next reconciliation cycle.</p><p>The result is a system that converges just as reliably, but gets there much faster.</p>]]></content:encoded></item><item><title><![CDATA[STACKIT and bunny.net partner to offer fast, private, regulation-ready content delivery]]></title><description><![CDATA[bunny.net teamed up with STACKIT, the cloud provider of Schwarz Digits, to deliver a high-performance, regulation-ready, EU-sovereign content delivery network (CDN) and edge security ecosystem built specifically for European businesses.]]></description><link>https://bunny.net/blog/stackit-and-bunny-net-partner-to-offer-fast-private-regulation-ready-content-delivery/</link><guid isPermaLink="false">6a168a5e160dc403fbfcf374</guid><category><![CDATA[News]]></category><category><![CDATA[Privacy]]></category><dc:creator><![CDATA[Graeme Inglis]]></dc:creator><pubDate>Wed, 27 May 2026 06:17:07 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/05/Stackit-and-bunny.net-Partnership-blog.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/05/Stackit-and-bunny.net-Partnership-blog.png" alt="STACKIT and bunny.net partner to offer fast, private, regulation-ready content delivery"><p>European infrastructure teams are facing a massive challenge.</p><p>With regulations like GDPR, NIS2, and the EU Data Act tightening across the continent, compliance is no longer a checkbox. It dictates your entire architecture. Historically, the industry narrative held that guaranteeing data sovereignty meant sacrificing raw edge performance.</p><p>We weren&#x2019;t willing to trade performance for sovereignty. Now, you don&#x2019;t have to either.</p><p>bunny.net teamed up with STACKIT, the cloud provider of Schwarz Digits, to deliver a high-performance, regulation-ready, EU-sovereign content delivery network (CDN) and edge security ecosystem built specifically for European businesses.</p><h2 id="uncompromising-performance-under-european-law">Uncompromising performance under European law</h2><p>This partnership brings together two teams obsessed with digital independence. If you aren&apos;t familiar with STACKIT, it is the cloud backbone of Schwarz Digits, the IT organization behind Schwarz Group. Operating out of its German headquarters, it has built an enterprise-grade cloud ecosystem focused entirely on data privacy and sovereign control.</p><p>By combining STACKIT&#x2019;s cloud infrastructure with bunny.net&#x2019;s global edge network, we are launching an integrated ecosystem that meets the needs of both your legal team and your DevOps leads.</p><p>When you route your traffic through STACKIT CDN, your data remains fully within EU jurisdiction. No hidden tracking cookies, no data monetization, and no third-party analytics processing your users&apos; information.</p><h2 id="engineering-edge-security-and-scale">Engineering edge security and scale</h2><p>We built this ecosystem to handle demanding, enterprise-scale workloads optimized for compliance.</p><p>Here is what this sovereign architecture delivers out of the box:</p><ul><li><strong>Global edge performance:</strong> Connect your STACKIT-hosted applications directly to a global edge network backed by 250 Tbps+ of backbone capacity, ensuring consistent sub-30 ms latency across Europe and beyond.</li><li><strong>Integrated edge protection:</strong> Mitigate risks before traffic hits your infrastructure. The platform injects robust security tools right at the edge, including a web application firewall (WAF), Distributed Denial-of-Service (DDoS) mitigation, and global rate limiting.</li><li><strong>Transparent cost infrastructure:</strong> Scale your application without worrying about complex pricing tiers or vendor lock-in. Keep track of your carrots with a predictable, transparent, pay-as-you-go cost model.</li></ul><h2 id="building-an-independent-digital-future">Building an independent digital future</h2><p>Your company&#x2019;s growth shouldn&#x2019;t be punished for protecting user privacy. By anchoring your traffic in a trust-first ecosystem like Stackit CDN, you can optimize your delivery pipeline, satisfy strict regulatory requirements, and maintain absolute control over your data.</p><p>Ready to see how the new sovereign edge can transform your stack? Hop on over to the <a href="https://stackit.com/en/products/network/stackit-cdn?_gl=1*1dqzp6s*_up*MQ">STACKIT platform</a> to learn more, or connect with our team to find out how to hop ahead securely.</p>]]></content:encoded></item><item><title><![CDATA[HopStart cohort #2: the three startups we're backing next]]></title><description><![CDATA[Picking just three was genuinely hard. There were plenty of products we'd have loved to back. Ultimately, we look for teams solving real problems for their users, especially when bunny.net is a natural fit for the infrastructure behind their product.]]></description><link>https://bunny.net/blog/hopstart-cohort-2-the-three-startups-were-backing-next/</link><guid isPermaLink="false">6a0ed12c160dc403fbfcf343</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Marek Nalikowski]]></dc:creator><pubDate>Thu, 21 May 2026 12:22:10 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/05/bunny.net-Hopstart-2nd-cohort-1.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/05/bunny.net-Hopstart-2nd-cohort-1.png" alt="HopStart cohort #2: the three startups we&apos;re backing next"><p>Back in January, we welcomed <a href="https://bunny.net/blog/announcing-hopstart-cohort-1-meet-the-3-startups-building-with-bunny-net/">the first group of founders</a> into HopStart, our program designed to take infrastructure off the plate of early-stage teams so they can pour their energy into the products they&apos;re building.</p><p>Since then, applications for the second round have rolled in, and the bar has only gone up. As a reminder, each cohort runs quarterly and supports three startups with up to <strong>$50,000 USD in credits</strong> for bunny.net, regular touchpoints with our team, and early access to features and products before they launch more broadly.</p><p>Picking just three was genuinely hard. There were plenty of products we&apos;d have loved to back. Ultimately, we look for teams solving real problems for their users, especially when bunny.net is a natural fit for the infrastructure behind their product.</p><p>Here are the three startups joining <strong>HopStart cohort #2</strong>.</p><h2 id="1st-place-sublimly">1st place: Sublimly</h2><p><strong>Founder:</strong> Patrick Faust</p><p><strong>Year founded:</strong> 2025</p><p><strong>URL:</strong> <a href="https://www.sublimly.com/">https://www.sublimly.com/</a></p><p><strong>Credits received:</strong> $50,000 for one year</p><p><strong>In the founder&apos;s words:</strong> &quot;Sublimly is a SaaS platform that empowers real estate agents, luxury brands, travel companies, and UGC creators to produce professional-quality videos and photos in minutes &#x2014; without any design or video editing skills. Users upload their raw photos, and our AI-powered pipeline handles the heavy lifting: intelligent photo enhancement, automated background removal, smart cropping, and AI-generated copy tailored to each industry context. Users then select a template adapted to their vertical, and Sublimly programmatically generates polished, branded video content ready for social media.&quot;</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/Sublimly-home-page.png" class="kg-image" alt="HopStart cohort #2: the three startups we&apos;re backing next" loading="lazy" width="2000" height="1008" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/Sublimly-home-page.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/05/Sublimly-home-page.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/05/Sublimly-home-page.png 1600w, https://bunny.net/blog/content/images/size/w2400/2026/05/Sublimly-home-page.png 2400w" sizes="(min-width: 720px) 720px"></figure><h2 id="2nd-place-writizzy">2nd place: Writizzy</h2><p><strong>Founder:</strong> Hugo Lassi&#xE8;ge and Thomas Sanlis</p><p><strong>Year founded:</strong> 2025</p><p><strong>URL:</strong> <a href="https://writizzy.com/">https://writizzy.com/</a></p><p><strong>Credits received:</strong> $25,000 for one year</p><p><strong>In the founders&apos; words:</strong> &quot;Writizzy is a European publishing platform designed as an open alternative to Substack or Medium. The goal isn&apos;t just to provide another CMS, but to give creators back control over their digital real estate. In an era of platform lock-in and centralized moderation, we provide a space where authors truly own their content and audience.&quot;</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/Writizzy-home-page-1.png" class="kg-image" alt="HopStart cohort #2: the three startups we&apos;re backing next" loading="lazy" width="2000" height="1070" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/Writizzy-home-page-1.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/05/Writizzy-home-page-1.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/05/Writizzy-home-page-1.png 1600w, https://bunny.net/blog/content/images/size/w2400/2026/05/Writizzy-home-page-1.png 2400w" sizes="(min-width: 720px) 720px"></figure><h2 id="3rd-place-scratchupload">3rd place: ScratchUpload</h2><p><strong>Founder:</strong> Nathaniel Sturtz</p><p><strong>Year founded:</strong> 2025</p><p><strong>URL:</strong> <a href="https://scratchupload.org/">https://scratchupload.org/</a></p><p><strong>Credits received:</strong> $10,000 for one year</p><p><strong>In the founder&apos;s words:</strong> &quot;ScratchUpload is leading the way in privacy- and accessibility-focused image hosting for an underserved community: users of SimplyPlural and Pluralkit.&quot;</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/ScratchUpload-home-page.png" class="kg-image" alt="HopStart cohort #2: the three startups we&apos;re backing next" loading="lazy" width="2000" height="847" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/ScratchUpload-home-page.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/05/ScratchUpload-home-page.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/05/ScratchUpload-home-page.png 1600w, https://bunny.net/blog/content/images/size/w2400/2026/05/ScratchUpload-home-page.png 2400w" sizes="(min-width: 720px) 720px"></figure><h2 id="apply-for-hopstart-cohort-3">Apply for HopStart cohort #3</h2><p>The window for the next round is open now. If you&apos;re building something with real ambition behind it, and you can see bunny.net playing a role in helping you get there, we&apos;d love to hear from you. Submit your application via <a href="https://bunny.net/HopStart/#HopStart-Form">the short form</a> before <strong>May 31</strong>.</p><p>If you applied previously and didn&apos;t make it through, please don&apos;t take that as a &#x2018;no&#x2019; for good. Fit shifts from one round to the next, and what didn&apos;t land before could be exactly the right fit this time.</p><p>We&apos;ll share the cohort #3 lineup in <strong>July</strong>.</p><p>We can&apos;t wait to see what you&apos;ve been working on!</p>]]></content:encoded></item><item><title><![CDATA[How to migrate from Vimeo to Bunny Stream]]></title><description><![CDATA[Bunny Stream is a feature-rich video hosting solution that is part of the bunny.net web infrastructure platform. Instead of subscription tiers, Bunny Stream uses a usage-based pricing model, where you’re charged only for the storage you use and the traffic your videos receive.]]></description><link>https://bunny.net/blog/how-to-migrate-from-vimeo-to-bunny-stream/</link><guid isPermaLink="false">6a018323160dc403fbfcf2a8</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Marek Nalikowski]]></dc:creator><pubDate>Wed, 13 May 2026 11:48:47 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/05/How-to-migrate-from-vimeo-to-bunny-stream.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/05/How-to-migrate-from-vimeo-to-bunny-stream.png" alt="How to migrate from Vimeo to Bunny Stream"><p>Whether you&apos;re a creator sharing your work through Vimeo or a developer integrating video into an app or platform, this guide walks you through how to migrate your video content to Bunny Stream.</p>
<!--kg-card-begin: html-->
<p>What we&#x2019;ll cover:</p>

<ul>
  <li>Choosing the right migration path for your situation</li>
  <li>
    How to move your video content manually with
    <a href="#manual-upload">direct upload</a>
    or
    <a href="#url-fetch">URL import</a>
  </li>
  <li>
    How to automate the process with
    <a href="#bulk-migration">CLI-based bulk migration</a>
  </li>
</ul>
<!--kg-card-end: html-->
<h2 id="why-migrate-from-vimeo-to-bunny-stream">Why migrate from Vimeo to Bunny Stream</h2><p><strong>Vimeo</strong> is an all-in-one SaaS platform for hosting, managing, and sharing video content. As of May 2026, it offers four paid tiers (Starter, Standard, Advanced, and Enterprise) billed either monthly or annually. Each tier comes with fixed storage and bandwidth limits, and more advanced features are only available in the higher subscription plans.</p><p><strong>Bunny Stream</strong> is a feature-rich video hosting solution that is part of the bunny.net web infrastructure platform. Instead of subscription tiers, Bunny Stream uses a usage-based pricing model, where <a href="https://bunny.net/pricing/stream/#calculator">you&#x2019;re charged only for the storage you use and the traffic your videos receive</a>.</p><p>With the exception of some <a href="https://bunny.net/stream/media-cage-enterprise-multi-drm-digital-rights-management/">enterprise-grade add-on features</a>, Bunny Stream offers most of its functionality to all users, regardless of how much they pay. By default, Bunny Stream is prepaid, meaning you purchase credits to use it, which ensures you&#x2019;re never hit with runaway costs (though postpaid is available on request).</p><p>Despite being part of an API-first web infrastructure platform, Bunny Stream has an intuitive user interface, that makes it accessible to non-technical users. With its attractive, usage-based pricing, it&#x2019;s very cost-effective for small creators and growing businesses alike.</p><h2 id="planning-your-migration-from-vimeo-to-bunny-stream">Planning your migration from Vimeo to Bunny Stream</h2><p>The right migration path depends mostly on the size of your video library and how you want to handle the process. In general, you have three options:</p><ul><li><strong>Manual upload</strong> &#x2192; best for small libraries; requires access to your original video files</li><li><strong>URL fetch</strong> &#x2192; also practical for smaller libraries; lets you import videos using a direct file URL from Vimeo without downloading them locally</li><li><strong>Bulk migration using the </strong><a href="https://docs.bunny.net/stream/vimeo2bunny"><strong>Vimeo2Bunny CLI tool</strong></a> &#x2192; best for larger libraries; automates the migration process</li></ul><p>Pick the option that works best for you, then follow the instructions below.</p>
<!--kg-card-begin: html-->
<h2 id="manual-upload">Migrate via manual upload</h2>
<!--kg-card-end: html-->
<p>Before you begin, <a href="https://dash.bunny.net/auth/register">sign up for a bunny.net account</a> and make sure you have all the videos you need saved on your computer.</p><ul><li><strong>Step 1:</strong> In your bunny.net dashboard, go to <strong>Stream.</strong></li><li><strong>Step 2:</strong> Click <strong>Add Video Library</strong>, then enter a name.</li><li><strong>Step 3:</strong> Once the library is created, select <strong>Upload a Video</strong>. You can upload multiple videos at once.</li><li><strong>Step 4:</strong> After the upload is complete, your video(s) will appear in the library. Selecting a video will take you to a screen like the one below, where you can edit its title, description, etc., as well as grab its URL or embed code.</li></ul><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/bunny.net-stream-migration.png" class="kg-image" alt="How to migrate from Vimeo to Bunny Stream" loading="lazy" width="2000" height="1026" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/bunny.net-stream-migration.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/05/bunny.net-stream-migration.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/05/bunny.net-stream-migration.png 1600w, https://bunny.net/blog/content/images/size/w2400/2026/05/bunny.net-stream-migration.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>And that&#x2019;s it: your videos are now ready to be shared.</p><ul><li>If you&#x2019;re sharing videos privately (e.g., sending a link to a client), just copy the <strong>Direct Play URL</strong> from the video&#x2019;s page in the dashboard.</li><li>If you&#x2019;re hosting videos on your website, replace your existing Vimeo embeds with Bunny Stream embed codes. You can adjust embed settings and copy the embed code from the video&#x2019;s page under <strong>Embed</strong>.</li></ul>
<!--kg-card-begin: html-->
<h2 id="url-fetch">Migrate via URL fetch</h2>
<!--kg-card-end: html-->
<p>Instead of uploading files from your computer, you can import videos using a direct file URL from Vimeo.</p><ul><li><strong>Step 1:</strong> In your bunny.net dashboard, go to <strong>Stream.</strong></li><li><strong>Step 2:</strong> Click <strong>Add Video Library</strong>, then enter a name.</li><li><strong>Step 3:</strong> Now go to your Vimeo account and navigate to your video library.</li><li><strong>Step 4:</strong> Choose a video you&#x2019;d like to move to Bunny Stream and click the <strong>Share</strong> dropdown (&quot;More sharing options&quot;) in the top-right corner.</li><li><strong>Step 5:</strong> Select <strong>Download</strong> &#x2014; you&#x2019;ll get a pop-up menu with available resolutions to download.</li><li><strong>Step 6:</strong> Right-click the download icon for the resolution you&#x2019;d like to upload and select <strong>Copy link address.</strong></li><li><strong>Step 7:</strong> Now go back to your bunny.net video library, click the dropdown next to <strong>Upload a Video,</strong> and select <strong>Upload Video from URL.</strong></li></ul><p></p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/bunny.net-stream-library.png" class="kg-image" alt="How to migrate from Vimeo to Bunny Stream" loading="lazy" width="1650" height="613" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/bunny.net-stream-library.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/05/bunny.net-stream-library.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/05/bunny.net-stream-library.png 1600w, https://bunny.net/blog/content/images/2026/05/bunny.net-stream-library.png 1650w" sizes="(min-width: 720px) 720px"></figure><ul><li><strong>Step 8</strong>: Paste the Vimeo URL from your clipboard into the URL field of the pop-up menu and click <strong>Upload Video</strong>.</li></ul><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/bunny.net-stream-upload.png" class="kg-image" alt="How to migrate from Vimeo to Bunny Stream" loading="lazy" width="1396" height="1060" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/bunny.net-stream-upload.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/05/bunny.net-stream-upload.png 1000w, https://bunny.net/blog/content/images/2026/05/bunny.net-stream-upload.png 1396w" sizes="(min-width: 720px) 720px"></figure><p>Note that, depending on your Vimeo video settings, you may need to add request headers and key values under <strong>Advanced</strong> if they are needed to authenticate the HTTP/HTTPS request to download your video.</p><p>As with manual uploads, if you&apos;re hosting videos on a website, you&apos;ll need to replace your existing Vimeo embeds with Bunny Stream embed codes. You can adjust embed settings and copy the embed code from the selected video&#x2019;s page under <strong>Embed</strong>.</p>
<!--kg-card-begin: html-->
<h2 id="bulk-migration">Bulk migration using Vimeo2Bunny CLI</h2>
<!--kg-card-end: html-->
<p>Both manual upload and URL fetch methods are impractical for larger libraries, so we built a CLI tool that automates the video migration using URLs.</p><p><a href="https://www.npmjs.com/package/vimeo2bunny">Vimeo2Bunny</a> is able to fetch all (or selected) videos and folders from your Vimeo account and create bunny.net collections matching your Vimeo folder structure.</p>
<!--kg-card-begin: html-->
<p>
  For each video, the tool gets a download URL from Vimeo and sends it to bunny.net&#x2019;s fetch endpoint. Bunny Stream downloads directly from Vimeo and copies title, description, and tags. The tool also saves progress to
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">~/.vimeo2bunny/migration-state.json</code>
  so migrations can be resumed if needed.
</p>
<!--kg-card-end: html-->
<p>You can run Vimeo2Bunny without installation using npx. If you don&#x2019;t have npx installed, follow this <a href="https://docs.npmjs.com/downloading-and-installing-node-js-and-npm">official installation guide</a>.</p><p>To use the tool, you&#x2019;ll need to first get the API credentials from both Vimeo and bunny.net.</p><h3 id="how-to-get-the-vimeo-access-token">How to get the Vimeo Access Token</h3><ul><li><strong>Step 1:</strong> Log in to the&#xA0;<a href="https://developer.vimeo.com/">Vimeo Developer Portal</a>&#xA0;and click <strong>Create an app.</strong></li><li><strong>Step 2:</strong> Enter a name for the app and add a brief description. Avoid including &#x201C;Vimeo&#x201D; in the name as it will be rejected.</li><li><strong>Step 3:</strong> Select&#xA0;<strong>No</strong>&#xA0;for &#x201C;Will people besides you be able to access your app?&#x201D;, then accept the terms and click&#xA0;<strong>Create App</strong>.</li><li><strong>Step 4:</strong> On your app&#x2019;s page, go to the&#xA0;<strong>Authentication</strong>&#xA0;section in the left sidebar.</li><li><strong>Step 5:</strong> Under&#xA0;<strong>Generate an access token</strong>, select&#xA0;<strong>Authenticated (you)</strong>&#xA0;and check the following scopes:</li></ul>
<!--kg-card-begin: html-->
<table style="border:1px solid rgba(24, 61, 109, 0.18); border-collapse:collapse; width:100%;">
  <thead>
    <tr style="font-weight:700; text-align:left; background:#223c6a; color:white;">
      <th style="padding:12px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Scope
      </th>
      <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Required
      </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Public</td>
      <td style="padding:12px 14px; vertical-align:middle; color:#183d6d;">&#x2714;&#xFE0F; Required (pre-selected)</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Private</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">&#x2714;&#xFE0F; Required</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Video Files</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">&#x2714;&#xFE0F; Required</td>
    </tr>
  </tbody>
</table>
<br>
<!--kg-card-end: html-->
<ul><li><strong>Step 6:</strong> Click&#xA0;<strong>Generate.</strong>&#xA0;Your personal access token will appear under&#xA0;<strong>Personal Access Tokens</strong>. Copy and store the token immediately, as it won&#x2019;t be shown again.</li></ul><h3 id="how-to-get-your-bunny-stream-credentials">How to get your Bunny Stream credentials</h3><ul><li>Step 1: Log in to&#xA0;bunny.net.</li><li>Step 2: Go to <strong>Stream</strong> and select a video library.</li><li>Step 3: Go to <strong>API</strong> and copy the <strong>Video</strong> <strong>Library ID</strong>&#xA0;and&#xA0;the <strong>API key</strong>.</li></ul><h3 id="how-to-use-the-vimeo2bunny-cli">How to use the Vimeo2Bunny CLI</h3>
<!--kg-card-begin: html-->
<p>
  If you&#x2019;re using npx
  (recommended), remember to prefix all commands with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">npx</code>.
  Alternatively, you can install from npm or clone the repo, as described
  <a href="https://docs.bunny.net/stream/vimeo2bunny#installation">here</a>.
</p>

<!--kg-card-end: html-->
<p>To get started, run:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

npx vimeo2bunny config

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  You&#x2019;ll be prompted to enter your Vimeo Access Token, Bunny Stream Library ID, and Bunny Stream API key. Alternatively, you can set environment variables (or use an 
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.env</code>
  file):
</p>

<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#ECEC93;">export</code> <code style="color:#F5B83D;">VIMEO_ACCESS_TOKEN=</code>your_token
<br>
<code style="color:#ECEC93;">export</code> <code style="color:#F5B83D;">BUNNY_LIBRARY_ID=</code>your_library_id
<br>
<code style="color:#ECEC93;">export</code> <code style="color:#F5B83D;">BUNNY_LIBRARY_API_KEY=</code>your_api_key

</div>
<br>
<!--kg-card-end: html-->
<p>Once configured, the tool lets you list your Vimeo content and migrate all or selected videos:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

vimeo2bunny list&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0; <code style="color:#998066;"># everything</code>
<br>
vimeo2bunny list --folders&#xA0;&#xA0; <code style="color:#998066;"># folders only</code>
<br>
vimeo2bunny list --videos&#xA0;&#xA0;&#xA0; <code style="color:#998066;"># videos only</code>
<br>
<br>
<code style="color:#998066;"># Preview what will happen (no changes made)</code>
<br>
vimeo2bunny migrate --dry-run
<br>
<br>
<code style="color:#998066;"># Run the migration</code>
<br>
vimeo2bunny migrate
<br>
<br>
<code style="color:#998066;"># Migrate a specific folder</code>
<br>
vimeo2bunny migrate --folder &lt;vimeo-folder-id&gt;
<br>
<br>
<code style="color:#998066;"># Adjust parallel transfers (default: 3, max: 20)</code>
<br>
vimeo2bunny migrate --concurrency 5
<br>
<br>
<code style="color:#998066;"># Resume an interrupted migration</code>
<br>
vimeo2bunny migrate --resume
<br>
<br>
<code style="color:#998066;"># Check migration status</code>
<br>
vimeo2bunny status

</div>
<br>
<!--kg-card-end: html-->
<p>Here&#x2019;s what gets mapped during bulk migration and how:</p>
<!--kg-card-begin: html-->
<table style="border:1px solid rgba(24, 61, 109, 0.18); border-collapse:collapse; width:100%;">
  <thead>
    <tr style="font-weight:700; text-align:left; background:#223c6a; color:white;">
      <th style="padding:12px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Vimeo
      </th>
      <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;">
        bunny.net
      </th>
      <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Notes
      </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">name</code>
      </td>
      <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">title</code>
      </td>
      <td style="padding:12px 14px; vertical-align:middle; color:#183d6d;">Direct mapping</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">description</code>
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">metaTags[description]</code>
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Stored as meta tag</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">tags</code>
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">metaTags[keywords]</code>
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Comma-separated</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">Folder</code>
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">Collection</code>
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Folder &#x2192; Collection</td>
    </tr>
  </tbody>
</table>
<br>
<!--kg-card-end: html-->
<p>After migrating your videos to Bunny Stream, don&#x2019;t forget to replace your existing Vimeo embeds with Bunny Stream embed codes. You can find more details about embedding videos <a href="https://docs.bunny.net/stream/embedding">in the docs</a>.</p><h2 id="wrapping-up">Wrapping up</h2><p>Migrating from Vimeo to Bunny Stream is straightforward once you&#x2019;ve chosen the approach that fits your setup.</p><ul><li>For smaller libraries, manual upload or URL fetch will get you up and running quickly.</li><li>For larger libraries, the Vimeo2Bunny CLI tool handles the migration efficiently and lets you resume progress if needed.</li></ul><p>Once your videos are in Bunny Stream, the final step is updating your embeds so your content continues to play on your website or app.</p><p><strong>A quick note:</strong> this guide and the Vimeo2Bunny tool are intended for migrating content you own or have permission to use. You&apos;re responsible for making sure your usage complies with Vimeo&apos;s terms.</p><h2 id="additional-resources">Additional resources</h2><ul><li><a href="https://docs.bunny.net/stream/quickstart">Bunny Stream quickstart</a></li><li><a href="https://docs.bunny.net/stream/dashboard">Managing videos in the dashboard</a></li><li><a href="https://docs.bunny.net/stream/url-fetch">Uploading videos from a remote URL</a></li><li><a href="https://docs.bunny.net/stream/vimeo2bunny">Vimeo2Bunny CLI tool documentation</a></li><li><a href="https://docs.bunny.net/stream/embedding">Embedding videos</a></li></ul>]]></content:encoded></item><item><title><![CDATA[Introducing the bunny.net CLI]]></title><description><![CDATA[Today we're releasing the bunny.net CLI, a single command-line tool for building and managing your entire bunny.net stack. This first release ships with full Database support, including the interactive SQL shell we introduced a few weeks ago. ]]></description><link>https://bunny.net/blog/introducing-the-bunny-net-cli/</link><guid isPermaLink="false">69f1afb3160dc403fbfcf1fd</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Jamie Barton]]></dc:creator><pubDate>Mon, 11 May 2026 11:35:00 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/05/bunny.net-CLI-1.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/05/bunny.net-CLI-1.png" alt="Introducing the bunny.net CLI"><p>The best developer tools don&apos;t ask you to context-switch. They meet you where you already are.</p><p>For most developers, that&apos;s the terminal. Not because it&apos;s retro or minimal, but because it&apos;s the one surface that works everywhere&#x2014;your local machine, your CI pipeline, your AI coding assistant. The commands you type are the same commands your automation runs. A good CLI isn&apos;t just a power-user shortcut. It&apos;s the most composable interface a platform can offer.</p><p>Today we&apos;re releasing the bunny.net<strong> CLI,</strong> a single command-line tool for building and managing your entire bunny.net stack. This first release ships with full Database support, including the <a href="https://bunny.net/blog/introducing-the-interactive-bunny-database-shell">interactive SQL shell</a> we introduced a few weeks ago. Support for Edge Scripting, Storage, Magic Containers, and more is coming soon.</p><p>The CLI is currently in <strong>public preview</strong>, and we&#x2019;re building it in the open. We&#x2019;d love your feedback, bug reports, and pull requests as we shape what comes next.</p><h2 id="up-and-running-in-three-commands">Up and running in three commands</h2><p>Install with npm:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; color:#998066; overflow:auto;">
<span style="color:#ECEC93;">npm install</span> <span style="color:#998066;">-</span><span style="color:#FFFFFF;">g</span> <span style="color:#FFFFFF;">@bunny.net/cli</span>

<span style="color:#998066;"># or</span>
<span style="color:#998066;"># curl -fsSL https://cli.bunny.net/install.sh | sh</span>
</pre>
<br>
<!--kg-card-end: html-->
<p>Log in:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny login

</div>
<br>
<!--kg-card-end: html-->
<p>This opens your browser, authenticates with your bunny.net account, and saves a profile locally. Note that it currently fetches your API key from your account once signed in.</p><p>Confirm it worked:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto;"><span style="color:#FFFFFF;">bunny </span><span style="color:#ECEC93;">whoami</span>
<span style="color:#FFFFFF;">Logged </span><span style="color:#779FC9;">in</span><span style="color:#FFFFFF;"> as Jamie Barton (jamie@bunny.net) &#x1F407;</span></pre>
<br>
<!--kg-card-end: html-->
<figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/bunny.net-CLI.png" class="kg-image" alt="Introducing the bunny.net CLI" loading="lazy" width="893" height="717" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/bunny.net-CLI.png 600w, https://bunny.net/blog/content/images/2026/05/bunny.net-CLI.png 893w" sizes="(min-width: 720px) 720px"></figure><h2 id="database-management-without-the-dashboard-detour">Database management without the dashboard detour</h2><p>The usual flow after creating a database is to open a dashboard, hunt for the connection string, copy it, paste it somewhere, hope you got the right one.</p><p>The CLI replaces all of that with a few commands.</p><h3 id="create-a-database">Create a database</h3>
<!--kg-card-begin: html-->
<p>
  Run <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db create</code> with no arguments and the CLI walks you through it interactively &#x2014; name, region, options:
</p>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db create

</div>
<br>
<!--kg-card-end: html-->
<p>Already know what you want? Pass the flags directly:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db create --name my-app-db --primary UK

</div>
<br>
<!--kg-card-end: html-->
<p>Both paths produce the same result. Interactive mode is great for exploring. Flags-first is what you&apos;ll reach for in scripts, pipelines, or when your AI assistant is driving.</p>
<!--kg-card-begin: html-->
<p>
  After creation, the CLI offers to link the database to your current directory, generate an auth token, and write credentials to
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.env</code>
  so you can go straight from
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">create</code>
  to querying in the same shell session.
</p>
<br>
<!--kg-card-end: html-->
<p>Inspect what you just created:</p>
<!--kg-card-begin: html-->
<pre style="background-color:#202020; padding:1em; border-radius:6px; font-family:monospace; overflow:auto; color:#FFFFFF;">bunny db show

 Key               Value
 ID                db_01KP8D0MKHB7HWEYYE6VX8CTG7
 Name              my-app-db
 URL               libsql://01KP8D0MGVG0PEWR9SCTND02PH-my-app-db.lite.bunnydb.net/
 Status            Active
 Size              <span style="color:#d1949e;">4.1 KB / 1024.0 MB</span>  &#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;&#x2591;  <span style="color:#d1949e;">0%</span>
 Storage Region    eu-west-1
 Primary Region    London (UK)
 Replica Regions   None</pre>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<br>
<h3>Linking a directory to a database</h3>

<p>
  Most <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">db</code> commands need to know which database you&apos;re targeting. Passing an ID every time gets old fast, so the CLI resolves the target database automatically in this order:
</p>

<ul>
  <li>An explicit <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">&lt;database-id&gt;</code> argument</li>
  <li>A <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.bunny/database.json</code> manifest created by <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db link</code></li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">BUNNY_DATABASE_URL</code> from a <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.env</code> file (walked up from the current directory)</li>
  <li>An interactive picker if none of the above match</li>
</ul>

<p>
  The recommended flow is <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db link</code>:
</p>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db <code style="color:#ECEC93;">link</code>

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  This writes a small
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.bunny/database.json</code>
  manifest to your project. Every
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">db</code>
  command run from that directory (
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">show</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">shell</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">studio</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">usage</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">tokens create</code>)
  now knows which database to target. No flags, no env var lookups, no connection strings in your shell history.
</p>

<p>
  Add
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.bunny/</code>
  to your
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.gitignore</code>.
  The manifest is a per-developer convenience, not something to commit. Each contributor links their own environment.
</p>

<p>
  If you&apos;d rather drive things from
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.env</code>
  (handy when the same
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">BUNNY_DATABASE_URL</code>
  is already wired into your app), the CLI picks that up automatically. And when you need to run a one-off command against a different database without changing context, pass the ID as an argument:
</p>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db show db_01KP8D0MKHB7HWEYYE6VX8CTG7

</div>
<br>
<!--kg-card-end: html-->
<h3 id="list-monitor-and-clean-up">List, monitor, and clean up</h3>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db list
<br>
bunny db delete
<br>
bunny db usage

</div>
<br>
<!--kg-card-end: html-->
<h3 id="multi-region-replication">Multi-region replication</h3><p>Bring data closer to your users with read replicas:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db regions list
<br>
bunny db regions <code style="color:#ECEC93;">add</code> --replicas UK,NY
<br>
bunny db regions remove --replicas NY

</div>
<br>
<!--kg-card-end: html-->
<h3 id="token-management">Token management</h3><p>Generate and revoke database tokens without touching the dashboard:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db tokens create
<br>
bunny db tokens create --read-only --expiry 30d
<br>
bunny db tokens invalidate

</div>
<br>
<!--kg-card-end: html-->
<h3 id="drop-into-the-interactive-shell">Drop into the interactive shell</h3><p>The CLI embeds the same <a href="https://bunny.net/blog/introducing-the-interactive-bunny-database-shell">@bunny.net/database-shell</a> we released as a standalone package, so you get the full interactive experience (dot-commands, saved views, output modes, sensitive column masking) with zero extra setup:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db shell

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

&#x2713; Connected to database
<br>
&#xA0;&#xA0;Type .help for commands, .quit to exit.
<br>
<br>
&#x2192;&#xA0;&#xA0;SELECT id, name, email FROM users;
<br>
&#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;
<br>
&#x2502; id &#x2502; name&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; email&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502;
<br>
&#x251C;&#x2500;&#x2500;&#x2500;&#x2500;&#x253C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x253C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2524;
<br>
&#x2502; 1&#xA0;&#xA0;&#x2502; Alice&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; a****e@example.com&#xA0;&#xA0;&#x2502;
<br>
&#x2502; 2&#xA0;&#xA0;&#x2502; Bob&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; b****b@example.com&#xA0;&#xA0;&#x2502;
<br>
&#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2534;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2534;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;
<br>
2 rows (4ms)

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  No connection strings. No token flags. Your authenticated profile handles it. Just
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db shell</code>
  and you&apos;re querying.
</p>
<!--kg-card-end: html-->
<p>Run a one-off query without entering the REPL:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db shell <code style="color:#ECEC93;">&lt;</code>database-id<code style="color:#ECEC93;">&gt;</code> <code style="color:#BDE052;">&quot;SELECT count(*) FROM orders&quot;</code>

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<h3>A read-only view with <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db studio</code></h3>

<p>
  Sometimes you don&apos;t want a REPL. You want to click through tables and see what&apos;s actually in there.
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db studio</code>
  spins up a local server and opens a read-only table viewer in your browser:
</p>

<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db studio

</div>
<br>
<p>
  It uses the same resolution order as every other
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">db</code>
  command, so if you&apos;ve run
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db link</code>
  it picks up the right database automatically. A short-lived auth token is generated for the session and discarded when you close it.
</p>
<br>
<!--kg-card-end: html-->
<figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/bunny-cli-read-only-view.png" class="kg-image" alt="Introducing the bunny.net CLI" loading="lazy" width="1066" height="557" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/bunny-cli-read-only-view.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/05/bunny-cli-read-only-view.png 1000w, https://bunny.net/blog/content/images/2026/05/bunny-cli-read-only-view.png 1066w" sizes="(min-width: 720px) 720px"></figure>
<!--kg-card-begin: html-->
<p>
  Studio is currently experimental and read-only by design, <strong>inspect, don&apos;t mutate</strong>. For schema changes and data edits, reach for
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db shell</code>.
</p>

<h2>Raw API access with <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny api</code></h2>

<p>
  Purpose-built commands cover the most common workflows, but sometimes you need to reach an endpoint that doesn&#x2019;t have a dedicated command yet, or you just want to poke at the API directly. That&#x2019;s what
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny api</code>
  is for.
</p>

<p>
  It&#x2019;s a raw, authenticated HTTP client for the entire bunny.net
  API. Pick a method, pass a path, and go:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

# List your pull zones
<br>
bunny api <code style="color:#D1949E;">GET</code> <code style="color:#ECEC93;">/</code>pullzone
<br>
<br>
# Grab a specific DNS zone
<br>
bunny api <code style="color:#D1949E;">GET</code> <code style="color:#ECEC93;">/</code>dnszone<code style="color:#ECEC93;">/</code><code style="color:#D1949E;">12345</code>
<br>
<br>
# Create a resource with a JSON body
<br>
bunny api <code style="color:#D1949E;">POST</code> <code style="color:#ECEC93;">/</code>videolibrary <code style="color:#ECEC93;">--</code>body <code style="color:#BDE052;">&apos;{&quot;Name&quot;:&quot;uploads&quot;,&quot;ReplicationRegions&quot;:[&quot;DE&quot;,&quot;NY&quot;]}&apos;</code>

</div>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Every request is automatically authenticated with your current profile, and every response is pretty-printed JSON by default. Pair it with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--output json</code>
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">jq</code>
  to build quick one-liners:
</p>
<br>
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny api <code style="color:#D1949E;">GET</code> <code style="color:#ECEC93;">/</code>pullzone <code style="color:#ECEC93;">--</code>output json <code style="color:#ECEC93;">|</code> jq <code style="color:#BDE052;">&apos;.[].Name&apos;</code>

</div>
<br>

<p>
  You can also pipe request bodies from stdin, which makes it easy to compose with other tools or feed in generated payloads:
</p>
<br>
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

cat payload.json <code style="color:#ECEC93;">|</code> bunny api <code style="color:#D1949E;">POST</code> <code style="color:#ECEC93;">/</code>dnszone

</div>
<br>

<p>
  Think of
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny api</code>
  as the escape hatch. If the
 bunny.net
  API supports it, you can call it from your terminal right now &#x2014; no wrapper command needed, no dashboard required. It&#x2019;s especially useful for AI agents that need to access endpoints beyond the built-in commands.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<h2>Built like the CLIs you already trust</h2>

<p>
  We followed the
  <a href="https://clig.dev/">Command Line Interface Guidelines</a>
  closely. The conventions that make
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">git</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">docker</code>,
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">kubectl</code>
  feel predictable - we wanted
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny</code>
  to feel that way from the start.
</p>

<p>
  <strong>Interactive or scriptable - your call.</strong>
  Prompts and spinners are TTY-aware. They show up in your terminal but stay out of the way in CI or when piping output.
</p>

<p>
  <strong>Structured output everywhere.</strong>
  Every command supports
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--output json</code>.
  Primary output goes to stdout, messages go to stderr, so piping always works cleanly:
</p>

<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny db list <code style="color:#ECEC93;">--</code>output json <code style="color:#ECEC93;">|</code> jq <code style="color:#BDE052;">&apos;.[].name&apos;</code>

</div>
<br>
<p>
  <strong>Clear, helpful errors.</strong>
  Errors are written in plain language with hints on how to fix them. Exit codes are meaningful:
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">1</code>
  for fixable problems,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">2</code>
  for internal errors. With
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--output json</code>,
  errors come back as structured JSON too.
</p>

<p>
  <strong>Respects your environment.</strong>
  The CLI honors
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">NO_COLOR</code>,
  supports named profiles for switching between accounts, and reads
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">BUNNYNET_API_KEY</code>
  from your environment when you&apos;d rather skip interactive login:
</p>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

bunny config profile create --name work
<br>
bunny login --profile work
<br>
bunny db list --profile work

</div>
<!--kg-card-end: html-->
<h2 id="your-ai-agent-already-knows-how-to-use-it">Your AI agent already knows how to use it</h2><p>There&apos;s a growing question in the developer tools space: do AI agents need custom protocols, or are the CLI tools developers already use enough?</p><p>Increasingly, the answer is that CLIs win.</p><p>AI models have been trained on billions of lines of terminal interactions. When an agent reaches for a CLI, it&apos;s drawing on deeply learned patterns, not adapting to a bespoke integration. Every bunny CLI command that returns structured JSON is a command an AI agent can already use, with zero extra setup.</p>
<!--kg-card-begin: html-->
<p>
  If you&apos;re working with Claude Code, Cursor, or Windsurf, your assistant can already create databases, manage tokens, and query data through the bunny CLI, the same way it uses
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">git</code>
  or
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">npm</code>
  today. No MCP server to configure. No schema tokens eating your context window. The CLI you use is the same interface your agent uses.
</p>
<!--kg-card-end: html-->
<p>The CLI comes with agent skills for databases and scripts so your agent knows how to get started. Below we can see Claude Code discovered the tool, skills, and created a database and schema for a new project.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/05/bunny.net-client-demo-1.png" class="kg-image" alt="Introducing the bunny.net CLI" loading="lazy" width="870" height="961" srcset="https://bunny.net/blog/content/images/size/w600/2026/05/bunny.net-client-demo-1.png 600w, https://bunny.net/blog/content/images/2026/05/bunny.net-client-demo-1.png 870w" sizes="(min-width: 720px) 720px"></figure><h2 id="whats-coming-next">What&apos;s coming next</h2><p>Database support is the starting point. Here&apos;s where we&apos;re headed.</p><h3 id="edge-scripting">Edge Scripting</h3><p>Scaffold a project, deploy serverless functions to the edge, and manage secrets, all from the terminal. Deployment history and rollbacks are on the roadmap too.</p><h3 id="storage">Storage</h3><p>Create buckets, list and sync files, and manage access to S3-compatible storage. The same operations you&apos;d do in the dashboard or with a third-party client, built directly into the CLI with your existing auth.</p><h3 id="magic-containers-apps">Magic Containers Apps</h3>
<!--kg-card-begin: html-->
<p>
  If you&apos;ve ever wanted to go from a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">Dockerfile</code>
  to a running, globally distributed application in a single command, that&apos;s what we&#x2019;re adding next.
</p>
<!--kg-card-end: html-->
<h3 id="the-bigger-picture">The bigger picture</h3><p>Our goal is a workflow where you go from an empty directory to a fully deployed application without leaving the terminal. Database provisioned, edge functions live, storage configured, containers scaled. Whether you&apos;re typing the commands or your AI assistant is running them, it&apos;s the same tool, the same output, the same interface.</p><p>Every bunny.net product, one CLI.</p><h2 id="built-in-the-open-built-in-typescript">Built in the open, built in TypeScript</h2><p>The bunny CLI is open source, and we&#x2019;re developing it in public. This isn&#x2019;t a finished product we&#x2019;re handing to developers. It&#x2019;s an active project we want to build with you.</p><p>The bunny.net developer platform is JavaScript-native. Edge Scripts run with Deno and Node.js compatibility. The developers building on bunny.net write JavaScript and TypeScript. By keeping the CLI in the same language, we can share code between the CLI and the scripts developers write. Use exports, generated clients, and utilities written for the CLI inside Edge Scripts.</p><p>One language for the CLI, the API client, the config schemas, and the edge runtime means fewer switches and a codebase that any JS developer can read, debug, or contribute to.</p><p>If something is broken, <a href="https://github.com/BunnyWay/cli/issues">open an issue</a>. If you want a feature, tell us. If you want to contribute, pull requests are welcome. The CLI is built with TypeScript, Yargs, and a lot of packages that should feel very familiar.</p><h2 id="get-started">Get started</h2>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#ECEC93;">npm install</code> -g @bunny.net/cli
<br>
bunny login

</div>
<br>
<!--kg-card-end: html-->
<p><a href="https://github.com/bunnyWay/cli" rel="noopener noreferrer">Star the repo on GitHub</a>, join us on <a href="https://discord.com/invite/bunnynet" rel="noopener noreferrer">Discord</a>, and let&#x2019;s build this together.</p>]]></content:encoded></item><item><title><![CDATA[Introducing Bunny Shield API Guardian: protection that understands your API]]></title><description><![CDATA[Today, we’re introducing API Guardian, a new layer of Bunny Shield that brings schema-aware protection to your APIs by turning your OpenAPI definition into enforceable validation at the edge.]]></description><link>https://bunny.net/blog/introducing-bunny-shield-api-guardian-protection-that-understands-your-api/</link><guid isPermaLink="false">69e88354160dc403fbfcf124</guid><category><![CDATA[News]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Joe Connolly]]></dc:creator><pubDate>Mon, 27 Apr 2026 06:00:00 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/04/Bunny-API-Guardian.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/04/Bunny-API-Guardian.png" alt="Introducing Bunny Shield API Guardian: protection that understands your API"><p>APIs sit at the center of almost everything you build today.</p><p>They handle authentication, power your frontend, connect your services, and move data between them. Requests are syntactically valid, responses return as expected, and systems stay online. Yet requests still reach your application that don&#x2019;t belong there.</p><p>When something goes wrong, it&#x2019;s rarely a dramatic outage, but something more subtle. A request your API doesn&#x2019;t define still slips through, a payload doesn&#x2019;t match what your application expects, or an endpoint gets called in a way that technically works but was never intended.</p><p>The problem is that most security systems are designed to detect what looks malicious. APIs don&#x2019;t always fail that way. Requests can be completely valid from an HTTP perspective and still violate how your application is supposed to behave.</p><p>That gap between what is valid and what is correct is where API abuse happens. We see it constantly at the edge, where traffic looks valid on the surface but doesn&#x2019;t make sense for the API it&#x2019;s targeting.</p><p>Today, we&#x2019;re introducing <strong>API Guardian</strong>, a new layer of Bunny Shield that brings schema-aware protection to your APIs by turning your OpenAPI definition into enforceable validation at the edge.</p><p>It builds on everything we&#x2019;ve shipped with Bunny Shield since launch. WAF, Global Rate Limiting, Bot Detection, and Access Lists control how traffic reaches your application, while Upload Scanning inspects what gets through. API Guardian delivers the final piece we set out to build. It validates requests against what your API is defined to accept and enforces it in real time.</p><p>Instead of asking whether a request looks suspicious, API Guardian asks whether it makes sense according to your API contract.</p><h3 id="validate-requests-at-the-edge-using-your-schema">Validate requests at the edge using your schema</h3><p>API Guardian starts with something you already have: your OpenAPI schema.</p><p>When you upload a spec, it becomes an enforceable contract at the edge. Each endpoint is translated into validation rules that run directly inside Bunny Shield&#x2019;s request security pipeline, so invalid requests can be filtered before they ever reach your origin.</p><p>Under the hood, the spec is normalized, schemas are extracted, and references are resolved (internal only) before being compiled into native WAF rules.</p><p>API Guardian enforces safety limits at upload time, including a maximum spec size of 2 MB. These limits cover nesting depth, total schema complexity, and regex usage. These guardrails ensure predictable performance and prevent excessively complex schemas from impacting validation at the edge.</p>
<!--kg-card-begin: html-->
<p>
  Validation covers the full request surface:
  path parameters, query parameters, headers, cookies, and JSON request bodies
  (<code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">application/json</code>
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">*+json</code>).
</p>
<!--kg-card-end: html-->
<p>You can log, block, or ignore requests that don&#x2019;t match, depending on how strictly you want to enforce your API surface.</p><p>Validation events that result in logging or blocking are recorded in your Bunny Shield event logs, so you can see what is being flagged and why.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bunny-Shield-event-logs.png" class="kg-image" alt="Introducing Bunny Shield API Guardian: protection that understands your API" loading="lazy" width="1563" height="948" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bunny-Shield-event-logs.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bunny-Shield-event-logs.png 1000w, https://bunny.net/blog/content/images/2026/04/Bunny-Shield-event-logs.png 1563w" sizes="(min-width: 720px) 720px"></figure><h3 id="keep-responses-aligned-with-your-contract">Keep responses aligned with your contract</h3><p>APIs define both the inputs your system accepts and the outputs it produces.</p><p>When requests are malformed or deliberately crafted, responses can behave in unintended ways. Error paths, edge cases, or inconsistent handling can expose fields or data that were never meant to be returned.</p><p>API Guardian can validate responses against your schema on a per-endpoint basis. When enabled, responses are checked before they leave your system, helping prevent unexpected or unsafe output from reaching clients.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/API-Guardian-responses-validation.png" class="kg-image" alt="Introducing Bunny Shield API Guardian: protection that understands your API" loading="lazy" width="1390" height="796" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/API-Guardian-responses-validation.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/API-Guardian-responses-validation.png 1000w, https://bunny.net/blog/content/images/2026/04/API-Guardian-responses-validation.png 1390w" sizes="(min-width: 720px) 720px"></figure><h3 id="enforce-authentication-at-the-edge">Enforce authentication at the edge</h3><p>APIs are harder to protect than traditional web traffic because requests often look valid at the protocol level, even when they&#x2019;re not meaningful for the application.</p><p>Most unwanted traffic still fails basic authentication checks. Bot traffic, scanners, and volumetric attacks rarely include correctly formed credentials, and almost never include valid tokens.</p><p>API Guardian enforces authentication requirements defined in your schema before requests reach your origin. It supports API keys (headers, query parameters, or cookies), HTTP authentication (Bearer and Basic), OAuth2, and OpenID Connect.</p><p>Requests missing required credentials are rejected immediately at the edge, reducing unnecessary load and filtering out a large portion of low-effort attack traffic.</p>
<!--kg-card-begin: html-->
<p>
  For Bearer tokens defined with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bearerFormat: jwt</code>,
  API Guardian performs basic checks such as validating structure and expiration. This allows you to filter out malformed or clearly invalid tokens before forwarding the request, while leaving full authentication and authorization to your application.
</p>
<!--kg-card-end: html-->
<p>For OpenID Connect schemes that use public-key signing, API Guardian can also verify token signatures at the edge. It retrieves the provider&#x2019;s configuration, resolves the advertised JWKS, and keeps keys updated as they rotate.</p><p>Tokens are validated against the expected issuer and signing algorithm before being forwarded. Invalid, expired, unsigned, or tampered tokens are rejected at the edge, ensuring only properly signed requests reach your origin.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bearer-tokens.png" class="kg-image" alt="Introducing Bunny Shield API Guardian: protection that understands your API" loading="lazy" width="1393" height="234" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bearer-tokens.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bearer-tokens.png 1000w, https://bunny.net/blog/content/images/2026/04/Bearer-tokens.png 1393w" sizes="(min-width: 720px) 720px"></figure><h3 id="apply-rate-limits-where-they-matter">Apply rate limits where they matter</h3><p>Not every endpoint behaves the same way, so protection shouldn&#x2019;t be uniform.</p><p>Some routes are public and inexpensive. Others are sensitive or resource-intensive. Applying a single rate limit across an entire API rarely reflects how real systems behave.</p><p>API Guardian allows you to define rate limits per endpoint, aligned directly with your API structure.</p>
<!--kg-card-begin: html-->
<p>
  Path templates such as
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">/users/{id}</code>
  work out of the box, so you don&#x2019;t need to define rules for every variation. Rate limits are applied at the template level, meaning all variations of a route share the same counter by default.
</p>

<p>
  You can define limits globally per endpoint or per IP address, with time windows ranging from one second to one hour. Versioned endpoints such as
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">/v1</code>
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">/v2</code>
  are treated independently, allowing you to tune protection as your API evolves.
</p>
<!--kg-card-end: html-->
<figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bunny-rate-limiting.png" class="kg-image" alt="Introducing Bunny Shield API Guardian: protection that understands your API" loading="lazy" width="1390" height="223" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bunny-rate-limiting.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bunny-rate-limiting.png 1000w, https://bunny.net/blog/content/images/2026/04/Bunny-rate-limiting.png 1390w" sizes="(min-width: 720px) 720px"></figure><h3 id="combine-structure-with-targeted-protection">Combine structure with targeted protection</h3><p>Schema validation ensures requests are structurally correct, based on your API schema. Some behaviors still fall outside structure alone.</p><p>API Guardian allows you to apply targeted injection detection to specific parameters. You can choose which query, path, header, or cookie values should be inspected for patterns like XSS or SQL injection.</p><p>This combines schema validation with traditional WAF protection, giving you precise control over where deeper inspection is applied.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bunny-injection-detection.png" class="kg-image" alt="Introducing Bunny Shield API Guardian: protection that understands your API" loading="lazy" width="1392" height="234" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bunny-injection-detection.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bunny-injection-detection.png 1000w, https://bunny.net/blog/content/images/2026/04/Bunny-injection-detection.png 1392w" sizes="(min-width: 720px) 720px"></figure><h3 id="built-into-the-edge-not-added-on-top">Built into the edge, not added on top</h3><p>API Guardian runs directly within the <a href="http://bunny.net/">bunny.net</a> edge as part of the existing request pipeline.</p><p>Validation happens in the same path as the rest of Bunny Shield, so requests don&#x2019;t take an extra hop. This keeps latency low while filtering invalid traffic before it reaches your origin, acting as a first layer of protection for APIs that would otherwise need to handle this traffic themselves.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bunny-shield-validation.png" class="kg-image" alt="Introducing Bunny Shield API Guardian: protection that understands your API" loading="lazy" width="1687" height="984" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bunny-shield-validation.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bunny-shield-validation.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/04/Bunny-shield-validation.png 1600w, https://bunny.net/blog/content/images/2026/04/Bunny-shield-validation.png 1687w" sizes="(min-width: 720px) 720px"></figure><h3 id="availability-and-limits">Availability and limits</h3><p>API Guardian is currently in <a href="https://docs.bunny.net/product-release-stages">public preview</a> on Bunny Shield <strong>Advanced plans and above</strong>.</p><p>We designed a simple, tiered structure to match the needs of different teams: Advanced and Business plans include clear endpoint limits, while Enterprise offers full customization. This keeps things straightforward and focused.</p><p>Endpoint limits scale by plan:</p><ul><li><strong>Advanced</strong>: up to 10 endpoints</li><li><strong>Business</strong>: up to 50 endpoints</li><li><strong>Enterprise</strong>: customizable based on your needs</li></ul><p>We set these limits intentionally. Rather than encouraging blanket protection across every single endpoint (which often leads to noise and maintenance overhead), we want to help teams focus on securing their highest-traffic, most sensitive, and business-critical endpoints.</p><p>API Guardian currently supports <strong>OpenAPI 3.0.x</strong> specifications.</p><p>You can re-upload and evolve your schema at any time. Existing endpoints retain their configuration, while removed endpoints are automatically cleaned up.</p><h3 id="what%E2%80%99s-next-for-bunny-shield">What&#x2019;s next for Bunny Shield</h3><p>API Guardian completes the initial vision for Bunny Shield, bringing structure and intent into how traffic is evaluated at the edge.</p><p>From here, the focus shifts to refining and expanding what&#x2019;s already there. That includes improving existing protections, advancing bot detection to better handle modern traffic patterns, and continuing to evolve the platform based on real-world usage and feedback.</p><p>Because <a href="http://bunny.net/">bunny.net</a> sits at the edge, our global network acts as your first and strongest line of defense. We designed Bunny Shield to secure all your workloads, from website traffic and APIs to AI models, stopping attacks before they ever hit your servers. We have an exciting roadmap ahead.</p><h3 id="start-protecting-your-api-contract">Start protecting your API contract</h3><p>Getting started is straightforward.</p><p>Upload your OpenAPI schema, refine validation, and apply authentication and rate limits per endpoint.</p><p>Start in log mode, observe what your API actually receives, then switch to blocking once you&apos;re confident.</p><p>Security shouldn&#x2019;t require complex rules or constant tuning. With API Guardian, your API contract becomes the source of truth.</p><p><a href="https://dash.bunny.net/auth/login">Log in</a> or <a href="https://dash.bunny.net/auth/register">sign up</a> to upload your schema and start protecting your API.</p>]]></content:encoded></item><item><title><![CDATA[How to add native video playback to your Expo app with Bunny Stream]]></title><description><![CDATA[Bunny Stream’s authentication model is designed around this pattern. The client requests access, your backend (or edge script) decides if it’s allowed, and returns the data needed for playback.]]></description><link>https://bunny.net/blog/native-video-playback-with-bunny-stream-and-expo/</link><guid isPermaLink="false">69e1eab2160dc403fbfcf049</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Jamie Barton]]></dc:creator><pubDate>Fri, 24 Apr 2026 05:00:00 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/04/Native-Video-Playback-Bunny-Stream-Expo.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/04/Native-Video-Playback-Bunny-Stream-Expo.png" alt="How to add native video playback to your Expo app with Bunny Stream"><p>When integrating <a href="https://docs.bunny.net/stream" rel="noopener noreferrer">Bunny Stream</a> into an <a href="https://expo.dev/" rel="noopener noreferrer">Expo</a> app, the easiest way to get started is to use <a href="https://docs.bunny.net/stream/player" rel="noopener noreferrer">Bunny Player</a>.</p>
<!--kg-card-begin: html-->
<p>
  Drop it into a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>
  and you get adaptive playback, built-in analytics, captions, chapters, and branding out of the box. It is quick to set up, and for many apps, that is exactly what you want.
</p>

<p>
  If your Expo app needs Picture-in-Picture, background audio, lock screen controls, or tighter playback control, the limitation is no longer the player; it is
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>.
  Unfortunately, the mobile OS will not float it for PiP or keep it alive like a native media session, and it will not treat it like a real platform media player.
</p>

<p>
  That is where
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  becomes useful.
</p>

<p>
  By playing the videos HLS URLs directly through the native video stack, AVPlayer on iOS and ExoPlayer on Android, you can keep your Bunny Stream as your video backend while unlocking the native playback features a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>
  cannot.
</p>

<p>
  In this guide, we will build a simple but real Expo app that does exactly that&#x2026; fetch your videos from Bunny Stream library, render a video list, and open a detail screen that plays videos natively with chapters, captions, and playback progress tracking.
</p>

<h2>Before you start</h2>

<p>You&#x2019;ll need:</p>

<ul>
  <li>An Expo project (SDK 52+) with Expo Router and Expo SDK 52 or later</li>
  <li>A Bunny Stream video library with at least one uploaded and processed video</li>
  <li>Your Pull Zone hostname, <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">vz-abc123-456.b-cdn.net</code>, found under <strong>Stream &gt; API</strong> in the <a href="http://bunny.net">bunny.net</a> dashboard</li>
  <li>Your Bunny Stream API key, used only in the edge script</li>
</ul>

<h2>Setting up</h2>

<p>
  Install
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-image</code>:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

npx expo <code style="color:#E1E395;">install</code> expo-video expo-image

</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<br>
<p>
  To enable Picture-in-Picture and background playback, add the
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  config plugin to
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">app.json</code>:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

{
<br>
&#xA0;&#xA0;<code style="color:#D1949E;">&quot;expo&quot;</code><code style="color:#D1949E;">:</code> {
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&quot;plugins&quot;</code><code style="color:#D1949E;">:</code> [
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;[
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">&quot;expo-video&quot;</code>,
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;{
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&quot;supportsBackgroundPlayback&quot;</code><code style="color:#D1949E;">:</code> <code style="color:#D1949E;">true</code>,
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&quot;supportsPictureInPicture&quot;</code><code style="color:#D1949E;">:</code> <code style="color:#D1949E;">true</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;}
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;]
<br>
&#xA0;&#xA0;&#xA0;&#xA0;]
<br>
&#xA0;&#xA0;}
<br>
}

</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<br>
<p>
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  requires a development build. It won&apos;t work in Expo Go. Run
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">npx expo run:ios</code>
  or
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">npx expo run:android</code>,
  or create a build with EAS.
</p>
<p>
  Next, copy
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.env</code>
  and fill in your Bunny Stream credentials:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
<code style="color:#ECEC93;">cp</code> .env .env.local
</div>
<br>
<!--kg-card-end: html-->
<p>Add your Bunny Stream values:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
EXPO_PUBLIC_BUNNY_PULL_ZONE=vz-abc123-456.b-cdn.net<br>
EXPO_PUBLIC_BUNNY_LIBRARY_ID=12345<br>
EXPO_PUBLIC_BUNNY_API_KEY=your-library-api-key
</div>
<br>
<!--kg-card-end: html-->
<p>Your API key is a secret. In a production app, proxy these calls through your backend or an API route so the key never reaches the client.</p><p>Then run:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
npx expo run:ios<br>
<code style="color:#666666;"># or</code><br>
npx expo run:android
</div>
<!--kg-card-end: html-->
<h3 id="how-bunny-stream-urls-work">How Bunny Stream URLs work</h3><p>Once a video has finished processing, Bunny Stream exposes it through predictable URLs built from your Pull Zone hostname and the video GUID.</p>
<!--kg-card-begin: html-->
<table style="border:1px solid rgba(24, 61, 109, 0.18); border-collapse:collapse; width:100%;">
  <thead>
    <tr style="font-weight:700; text-align:left; background:#223c6a; color:white;">
      <th style="padding:12px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Resource
      </th>
      <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); color:white;">
        URL pattern
      </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        HLS playlist
      </td>
      <td style="padding:12px 14px; vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:4px 8px; border-radius:4px;">https://{pullZone}/{videoId}/playlist.m3u8</code>
      </td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        Thumbnail
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:4px 8px; border-radius:4px;">https://{pullZone}/{videoId}/{thumbnailFileName}</code>
      </td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        Animated preview
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:4px 8px; border-radius:4px;">https://{pullZone}/{videoId}/preview.webp</code>
      </td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        MP4 fallback
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:4px 8px; border-radius:4px;">https://{pullZoe}/{videoId}/play_{height}p.mp4</code>
      </td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        Captions
      </td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:4px 8px; border-radius:4px;">htps://{pullZone}/{videoId}/captions/{lang}.vtt</code>
      </td>
    </tr>
  </tbody>
</table>
<br>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  The most important URL here is the HLS playlist. That is what
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  plays. Because it is adaptive, the player can automatically choose the best quality for the current network conditions.
</p>
<!--kg-card-end: html-->
<h3 id="using-an-edge-script-for-secure-api-access">Using an edge script for secure API access</h3><p>In this example, we do <strong>not</strong> call the Bunny Stream API directly from the app to fetch videos that we want to list in our app. Instead, we route all requests through a Bunny Edge Script.</p><p>Bunny Stream&#x2019;s authentication model is designed around this pattern. The client requests access, your backend (or edge script) decides if it&#x2019;s allowed, and returns the data needed for playback.</p><h3 id="example-edge-script">Example edge script</h3><p>Below is an example of how you could create an edge script that proxies Bunny Stream:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
BunnySDK.net.http.<code style="color:#ECEC93;">serve</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#779FC9;">async</code> <code style="color:rgba(255,255,255,0.7)">(</code>request<code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">const</code> url <code style="color:#F5B83D;">=</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">URL</code><code style="color:rgba(255,255,255,0.7)">(</code>request.url<code style="color:rgba(255,255,255,0.7)">);</code><br>
<br>
&#xA0;&#xA0;<code style="color:#998066;">// GET /videos</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">if</code> <code style="color:rgba(255,255,255,0.7)">(</code>url.pathname <code style="color:#F5B83D;">===</code> <code style="color:#B9D55E;">&quot;/videos&quot;</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#779FC9;">const</code> apiUrl <code style="color:#F5B83D;">=</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">URL</code><code style="color:rgba(255,255,255,0.7)">(</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#B9D55E;">&quot;https://video.bunnycdn.com/library/1245/videos&quot;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">);</code><br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;apiUrl.search <code style="color:#F5B83D;">=</code> url.search<code style="color:rgba(255,255,255,0.7)">;</code><br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#779FC9;">const</code> res <code style="color:#F5B83D;">=</code> <code style="color:#779FC9;">await</code> <code style="color:#ECEC93;">fetch</code><code style="color:rgba(255,255,255,0.7)">(</code>apiUrl.<code style="color:#ECEC93;">toString</code><code style="color:rgba(255,255,255,0.7)">()</code>, <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;headers<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;AccessKey<code style="color:#F5B83D;">:</code> BunnySDK.env.<code style="color:#D1949E;">VIDEO_LIBRARY_API_KEY</code><code style="color:rgba(255,255,255,0.7)">,</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">});</code><br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#779FC9;">return</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">Response</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#779FC9;">await</code> res.<code style="color:#ECEC93;">text</code><code style="color:rgba(255,255,255,0.7)">()</code>, <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;headers<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#D1949E;">&quot;Content-Type&quot;</code><code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">&quot;application/json&quot;</code> <code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">});</code><br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><br>
<br>
&#xA0;&#xA0;<code style="color:#998066;">// GET /videos/:id</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">if</code> <code style="color:rgba(255,255,255,0.7)">(</code>url.pathname.<code style="color:#ECEC93;">startsWith</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">&quot;/videos/&quot;</code><code style="color:rgba(255,255,255,0.7)">))</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#779FC9;">const</code> <code style="color:#D1949E;">id</code> <code style="color:#F5B83D;">=</code> url.pathname.<code style="color:#ECEC93;">split</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">&quot;/&quot;</code><code style="color:rgba(255,255,255,0.7)">)</code>[2]<code style="color:rgba(255,255,255,0.7)">;</code><br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#779FC9;">const</code> res <code style="color:#F5B83D;">=</code> <code style="color:#779FC9;">await</code> <code style="color:#ECEC93;">fetch</code><code style="color:rgba(255,255,255,0.7)">(</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#B9D55E;">`https://video.bunnycdn.com/library/12345/videos/${</code><code style="color:#D1949E;">id</code><code style="color:#B9D55E;">}`</code><code style="color:rgba(255,255,255,0.7)">,</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;headers<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;AccessKey<code style="color:#F5B83D;">:</code> BunnySDK.env.<code style="color:#D1949E;">VIDEO_LIBRARY_API_KEY</code><code style="color:rgba(255,255,255,0.7)">,</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">);</code><br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#779FC9;">return</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">Response</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#779FC9;">await</code> res.<code style="color:#ECEC93;">text</code><code style="color:rgba(255,255,255,0.7)">()</code>, <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;headers<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#D1949E;">&quot;Content-Type&quot;</code><code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">&quot;application/json&quot;</code> <code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">});</code><br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><br>
<br>
&#xA0;&#xA0;<code style="color:#779FC9;">return</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">Response</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">&quot;Not found&quot;</code>, <code style="color:rgba(255,255,255,0.7)">{</code> status<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">404</code> <code style="color:rgba(255,255,255,0.7)">});</code><br>
<code style="color:rgba(255,255,255,0.7)">});</code>
</div>
<!--kg-card-end: html-->
<h3 id="talking-to-the-bunny-stream-api">Talking to the Bunny Stream API</h3><p>Now your Expo app talks to your edge script instead of bunny.net directly.</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
<code style="color:#998066;">// lib/bunny.ts</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">const</code> <code style="color:#D1949E;">PULL_ZONE</code> <code style="color:#F5B83D;">=</code> process.env.<code style="color:#D1949E;">EXPO_PUBLIC_BUNNY_PULL_ZONE</code> <code style="color:rgba(255,255,255,0.7)">??</code> <code style="color:#B9D55E;">&quot;&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">const</code> <code style="color:#D1949E;">API_BASE</code> <code style="color:#F5B83D;">=</code> process.env.<code style="color:#D1949E;">EXPO_PUBLIC_API_BASE</code> <code style="color:rgba(255,255,255,0.7)">??</code> <code style="color:#B9D55E;">&quot;&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">type</code> <code style="color:#ECEC93;">BunnyCaption</code> <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code> srclang<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">;</code> label<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code> <code style="color:rgba(255,255,255,0.7)">};</code><br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">type</code> <code style="color:#ECEC93;">BunnyChapter</code> <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code> title<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">;</code> start<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code> end<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code> <code style="color:rgba(255,255,255,0.7)">};</code><br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">type</code> <code style="color:#ECEC93;">BunnyMoment</code> <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code> label<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">;</code> timestamp<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code> <code style="color:rgba(255,255,255,0.7)">};</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">type</code> <code style="color:#ECEC93;">BunnyVideo</code> <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;guid<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;title<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;description<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code> <code style="color:rgba(255,255,255,0.7)">|</code> <code style="color:#779FC9;">null</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;length<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;width<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;height<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;views<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;status<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;thumbnailFileName<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code> <code style="color:rgba(255,255,255,0.7)">|</code> <code style="color:#779FC9;">null</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;thumbnailBlurhash<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code> <code style="color:rgba(255,255,255,0.7)">|</code> <code style="color:#779FC9;">null</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;captions<code style="color:#F5B83D;">:</code> <code style="color:#ECEC93;">BunnyCaption</code><code style="color:rgba(255,255,255,0.7)">[]</code> <code style="color:rgba(255,255,255,0.7)">|</code> <code style="color:#779FC9;">null</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;chapters<code style="color:#F5B83D;">:</code> <code style="color:#ECEC93;">BunnyChapter</code><code style="color:rgba(255,255,255,0.7)">[]</code> <code style="color:rgba(255,255,255,0.7)">|</code> <code style="color:#779FC9;">null</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;moments<code style="color:#F5B83D;">:</code> <code style="color:#ECEC93;">BunnyMoment</code><code style="color:rgba(255,255,255,0.7)">[]</code> <code style="color:rgba(255,255,255,0.7)">|</code> <code style="color:#779FC9;">null</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;availableResolutions<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code> <code style="color:rgba(255,255,255,0.7)">|</code> <code style="color:#779FC9;">null</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;hasMP4Fallback<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">boolean</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:rgba(255,255,255,0.7)">};</code><br>
<br>
<code style="color:#779FC9;">type</code> ListResponse <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;totalItems<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;currentPage<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;itemsPerPage<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
&#xA0;&#xA0;items<code style="color:#F5B83D;">:</code> <code style="color:#ECEC93;">BunnyVideo</code><code style="color:rgba(255,255,255,0.7)">[];</code><br>
<code style="color:rgba(255,255,255,0.7)">};</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">async</code> <code style="color:#779FC9;">function</code> <code style="color:#ECEC93;">listVideos</code><code style="color:rgba(255,255,255,0.7)">(</code><br>
&#xA0;&#xA0;page <code style="color:#F5B83D;">=</code> <code style="color:#D1949E;">1</code><code style="color:rgba(255,255,255,0.7)">,</code><br>
&#xA0;&#xA0;perPage <code style="color:#F5B83D;">=</code> <code style="color:#D1949E;">20</code><code style="color:rgba(255,255,255,0.7)">,</code><br>
<code style="color:rgba(255,255,255,0.7)">):</code> <code style="color:#B9D55E;">Promise</code><code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">ListResponse</code><code style="color:rgba(255,255,255,0.7)">&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">const</code> url <code style="color:#F5B83D;">=</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">URL</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">${</code><code style="color:#D1949E;">API_BASE</code><code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">/videos`</code><code style="color:rgba(255,255,255,0.7)">);</code><br>
&#xA0;&#xA0;url.searchParams.<code style="color:#ECEC93;">set</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">&quot;page&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code> <code style="color:#ECEC93;">String</code><code style="color:rgba(255,255,255,0.7)">(</code>page<code style="color:rgba(255,255,255,0.7)">));</code><br>
&#xA0;&#xA0;url.searchParams.<code style="color:#ECEC93;">set</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">&quot;itemsPerPage&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code> <code style="color:#ECEC93;">String</code><code style="color:rgba(255,255,255,0.7)">(</code>perPage<code style="color:rgba(255,255,255,0.7)">));</code><br>
<br>
&#xA0;&#xA0;<code style="color:#779FC9;">const</code> res <code style="color:#F5B83D;">=</code> <code style="color:#779FC9;">await</code> <code style="color:#ECEC93;">fetch</code><code style="color:rgba(255,255,255,0.7)">(</code>url<code style="color:rgba(255,255,255,0.7)">);</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">if</code> <code style="color:rgba(255,255,255,0.7)">(!</code>res.ok<code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#779FC9;">throw</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">Error</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">`API error: </code><code style="color:rgba(255,255,255,0.7)">${</code>res.status<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">);</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">return</code> res.<code style="color:#ECEC93;">json</code><code style="color:rgba(255,255,255,0.7)">();</code><br>
<code style="color:rgba(255,255,255,0.7)">}</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">async</code> <code style="color:#779FC9;">function</code> <code style="color:#ECEC93;">getVideo</code><code style="color:rgba(255,255,255,0.7)">(</code>videoId<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">):</code> <code style="color:#B9D55E;">Promise</code><code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">BunnyVideo</code><code style="color:rgba(255,255,255,0.7)">&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">const</code> res <code style="color:#F5B83D;">=</code> <code style="color:#779FC9;">await</code> <code style="color:#ECEC93;">fetch</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">${</code><code style="color:#D1949E;">API_BASE</code><code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">/videos/</code><code style="color:rgba(255,255,255,0.7)">${</code>videoId<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">);</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">if</code> <code style="color:rgba(255,255,255,0.7)">(!</code>res.ok<code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#779FC9;">throw</code> <code style="color:#779FC9;">new</code> <code style="color:#ECEC93;">Error</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#B9D55E;">`API error: </code><code style="color:rgba(255,255,255,0.7)">${</code>res.status<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">);</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">return</code> res.<code style="color:#ECEC93;">json</code><code style="color:rgba(255,255,255,0.7)">();</code><br>
<code style="color:rgba(255,255,255,0.7)">}</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">function</code> <code style="color:#ECEC93;">thumbnailUrl</code><code style="color:rgba(255,255,255,0.7)">(</code>videoId<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">,</code> fileName<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">return</code> <code style="color:#B9D55E;">`https://</code><code style="color:rgba(255,255,255,0.7)">${</code><code style="color:#D1949E;">PULL_ZONE</code><code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">/</code><code style="color:rgba(255,255,255,0.7)">${</code>videoId<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">/</code><code style="color:rgba(255,255,255,0.7)">${</code>fileName<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:rgba(255,255,255,0.7)">}</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">function</code> <code style="color:#ECEC93;">hlsUrl</code><code style="color:rgba(255,255,255,0.7)">(</code>videoId<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">string</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">return</code> <code style="color:#B9D55E;">`https://</code><code style="color:rgba(255,255,255,0.7)">${</code><code style="color:#D1949E;">PULL_ZONE</code><code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">/</code><code style="color:rgba(255,255,255,0.7)">${</code>videoId<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">/playlist.m3u8`</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:rgba(255,255,255,0.7)">}</code><br>
<br>
<code style="color:#779FC9;">export</code> <code style="color:#779FC9;">function</code> <code style="color:#ECEC93;">formatTime</code><code style="color:rgba(255,255,255,0.7)">(</code>s<code style="color:#F5B83D;">:</code> <code style="color:#B9D55E;">number</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">const</code> m <code style="color:#F5B83D;">=</code> Math.<code style="color:#ECEC93;">floor</code><code style="color:rgba(255,255,255,0.7)">(</code>s <code style="color:#F5B83D;">/</code> 60<code style="color:rgba(255,255,255,0.7)">);</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">const</code> sec <code style="color:#F5B83D;">=</code> Math.<code style="color:#ECEC93;">floor</code><code style="color:rgba(255,255,255,0.7)">(</code>s <code style="color:#F5B83D;">%</code> 60<code style="color:rgba(255,255,255,0.7)">);</code><br>
&#xA0;&#xA0;<code style="color:#779FC9;">return</code> <code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">${</code>m<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">:</code><code style="color:rgba(255,255,255,0.7)">${</code>sec.<code style="color:#ECEC93;">toString</code><code style="color:rgba(255,255,255,0.7)">()</code>.<code style="color:#ECEC93;">padStart</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#D1949E;">2</code><code style="color:rgba(255,255,255,0.7)">,</code> <code style="color:#B9D55E;">&quot;0&quot;</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#B9D55E;">`</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:rgba(255,255,255,0.7)">}</code>
</div>
<!--kg-card-end: html-->
<h3 id="root-layout">Root layout</h3><p>We only need two screens: a list screen and a detail screen.</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
<code style="color:#998066;">// app/_layout.tsx</code><br>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> DarkTheme, DefaultTheme, ThemeProvider <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&apos;@react-navigation/native&apos;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> Stack <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&apos;expo-router&apos;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#66A2CC;">import</code> React <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&apos;react&apos;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> useColorScheme <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&apos;react-native&apos;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<br>
<code style="color:#66A2CC;">export</code> <code style="color:#66A2CC;">default</code> <code style="color:#66A2CC;">function</code> <code style="color:#ECEC93;">RootLayout</code><code style="color:rgba(255,255,255,0.7)">()</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:#ffffff;">colorScheme</code> <code style="color:#F5B83D;">=</code> <code style="color:#ECEC93;">useColorScheme</code><code style="color:rgba(255,255,255,0.7)">();</code><br>
&#xA0;&#xA0;<code style="color:#66A2CC;">return</code> <code style="color:rgba(255,255,255,0.7)">(</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">ThemeProvider</code> <code style="color:#BDE052;">value</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:#D1949E;">colorScheme</code> <code style="color:#F5B83D;">===</code> <code style="color:#BDE052;">&apos;dark&apos;</code> <code style="color:#F5B83D;">?</code> <code style="color:#D1949E;">DarkTheme</code> <code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">DefaultTheme</code><code style="color:rgba(255,255,255,0.7)">}&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">Stack</code><code style="color:rgba(255,255,255,0.7)">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">Stack.Screen</code> <code style="color:#BDE052;">name</code><code style="color:#F5B83D;">=</code><code style="color:#66A2CC;">&quot;index&quot;</code> <code style="color:#BDE052;">options</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{{</code> <code style="color:#D1949E;">title</code><code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&apos;Videos&apos;</code> <code style="color:rgba(255,255,255,0.7)">}}</code> <code style="color:rgba(255,255,255,0.7)">/&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">Stack.Screen</code> <code style="color:#BDE052;">name</code><code style="color:#F5B83D;">=</code><code style="color:#66A2CC;">&quot;video/[id]&quot;</code> <code style="color:#BDE052;">options</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{{</code> <code style="color:#D1949E;">title</code><code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&apos;&apos;</code> <code style="color:rgba(255,255,255,0.7)">}}</code> <code style="color:rgba(255,255,255,0.7)">/&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;/</code><code style="color:#ECEC93;">Stack</code><code style="color:rgba(255,255,255,0.7)">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;/</code><code style="color:#ECEC93;">ThemeProvider</code><code style="color:rgba(255,255,255,0.7)">&gt;</code><br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">);</code><br>
<code style="color:rgba(255,255,255,0.7)">}</code>
</div>
<!--kg-card-end: html-->
<h3 id="building-the-video-list">Building the video list</h3><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/bunny-video-list.png" class="kg-image" alt="How to add native video playback to your Expo app with Bunny Stream" loading="lazy" width="1020" height="976" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/bunny-video-list.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/bunny-video-list.png 1000w, https://bunny.net/blog/content/images/2026/04/bunny-video-list.png 1020w" sizes="(min-width: 720px) 720px"></figure><p>The home screen fetches your Bunny Stream library and displays a scrollable list of videos. Each row renders a thumbnail, a title, and a small metadata line. Tapping a card opens the native player screen for that video.</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
<code style="color:#998066;">// app/index.tsx</code><br>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#ffffff;">useEffect</code>, <code style="color:#ffffff;">useState</code> <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;react&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#ffffff;">View</code>,<br>
&#xA0;&#xA0;<code style="color:#ffffff;">Text</code>,<br>
&#xA0;&#xA0;<code style="color:#ffffff;">FlatList</code>,<br>
&#xA0;&#xA0;<code style="color:#ffffff;">Pressable</code>,<br>
&#xA0;&#xA0;<code style="color:#ffffff;">ActivityIndicator</code>,<br>
&#xA0;&#xA0;<code style="color:#ffffff;">StyleSheet</code>,<br>
<code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;react-native&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#ffffff;">Image</code> <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo-image&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#ffffff;">useRouter</code> <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo-router&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#ffffff;">listVideos</code>,<br>
&#xA0;&#xA0;<code style="color:#ffffff;">thumbnailUrl</code>,<br>
&#xA0;&#xA0;<code style="color:#ffffff;">formatTime</code>,<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">type</code> <code style="color:#ECEC93;">BunnyVideo</code>,<br>
<code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;../lib/bunny&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code><br>
<br>
<code style="color:#66A2CC;">export</code> <code style="color:#66A2CC;">default</code> <code style="color:#66A2CC;">function</code> <code style="color:#ECEC93;">VideoListScreen</code><code style="color:rgba(255,255,255,0.7)">()</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:#ffffff;">router</code> <code style="color:#F5B83D;">=</code> <code style="color:#ffffff;">useRouter</code><code style="color:rgba(255,255,255,0.7)">();</code><br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> [<code style="color:#ffffff;">videos</code>, <code style="color:#ffffff;">setVideos</code>] <code style="color:#F5B83D;">=</code> <code style="color:#ECEC93;">useState</code><code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">BunnyVideo</code><code style="color:rgba(255,255,255,0.7)">[]&gt;([]);</code><br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> [<code style="color:#ffffff;">loading</code>, <code style="color:#ffffff;">setLoading</code>] <code style="color:#F5B83D;">=</code> <code style="color:#ECEC93;">useState</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#D1949E;">true</code><code style="color:rgba(255,255,255,0.7)">);</code><br>
<br>
&#xA0;&#xA0;<code style="color:#ffffff;">useEffect</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#ffffff;">()</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#ffffff;">listVideos</code><code style="color:rgba(255,255,255,0.7)">()</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;.<code style="color:#ECEC93;">then</code><code style="color:rgba(255,255,255,0.7)">((</code><code style="color:#ffffff;">data</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:#ffffff;">setVideos</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#ffffff;">data</code>.<code style="color:#ffffff;">items</code><code style="color:rgba(255,255,255,0.7)">))</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;.<code style="color:#ECEC93;">catch</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#BDE052;">console</code>.<code style="color:#ffffff;">error</code><code style="color:rgba(255,255,255,0.7)">)</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;.<code style="color:#ECEC93;">finally</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#ffffff;">()</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:#ECEC93;">setLoading</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#D1949E;">false</code><code style="color:rgba(255,255,255,0.7)">));</code><br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}, []);</code><br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">if</code> <code style="color:#ffffff;">(loading)</code> <code style="color:#ffffff;">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#66A2CC;">return</code> <code style="color:#ffffff;">(</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#ECEC93;">&lt;View</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.center}&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#ECEC93;">&lt;ActivityIndicator</code> <code style="color:#BDE052;">size</code><code style="color:#66A2CC;">=</code><code style="color:#66A2CC;">&quot;large&quot;</code> <code style="color:#BDE052;">color</code><code style="color:#66A2CC;">=</code><code style="color:#66A2CC;">&quot;#FF6B00&quot;</code> <code style="color:#D1949E;">/&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#ECEC93;">&lt;/View&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#ffffff;">);</code><br>
&#xA0;&#xA0;<code style="color:#ffffff;">}</code><br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">return</code> <code style="color:rgba(255,255,255,0.7)">(</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">FlatList</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">data</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{videos}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">keyExtractor</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{(v) </code><code style="color:#F5B83D;">=&gt;</code><code style="color:#D1949E;"> v.guid}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">contentContainerStyle</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{styles.list}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">renderItem</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{({ item }) </code><code style="color:#F5B83D;">=&gt;</code><code style="color:rgba(255,255,255,0.7)"> (</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">Pressable</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">style</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{styles.card}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">onPress</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{() </code><code style="color:#F5B83D;">=&gt;</code><code style="color:#D1949E;"> router.</code><code style="color:#ECEC93;">push</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#BDE052;">`/video/</code><code style="color:#D1949E;">${item.guid}</code><code style="color:#BDE052;">`</code><code style="color:rgba(255,255,255,0.7)">)}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">Image</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">source</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">item.thumbnailFileName</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#F5B83D;">?</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#D1949E;">uri</code><code style="color:#F5B83D;">:</code> <code style="color:#ECEC93;">thumbnailUrl</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#D1949E;">item.guid, item.thumbnailFileName</code><code style="color:rgba(255,255,255,0.7)">) }</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#F5B83D;">:</code> <code style="color:#66A2CC;">undefined</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">placeholder</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">item.thumbnailBlurhash</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#F5B83D;">?</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#D1949E;">blurhash</code><code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">item.thumbnailBlurhash</code> <code style="color:rgba(255,255,255,0.7)">}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#F5B83D;">:</code> <code style="color:#66A2CC;">undefined</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">style</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{styles.thumbnail}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">contentFit</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#66A2CC;">&quot;cover&quot;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">transition</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:#D1949E;">200</code><code style="color:rgba(255,255,255,0.7)">}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">/&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">View</code> <code style="color:#BDE052;">style</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{styles.cardBody}</code><code style="color:rgba(255,255,255,0.7)">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.title}</code> <code style="color:#BDE052;">numberOfLines</code><code style="color:#D1949E;">={2}&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">{item.title}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#ECEC93;">Text</code><code style="color:#D1949E;">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">&lt;</code><code style="color:#ECEC93;">Text</code> <code style="color:#BDE052;">style</code><code style="color:rgba(255,255,255,0.7)">=</code><code style="color:#D1949E;">{styles.meta}</code><code style="color:rgba(255,255,255,0.7)">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code><code style="color:#ECEC93;">formatTime</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#D1949E;">item.length</code><code style="color:rgba(255,255,255,0.7)">)} &#xB7; {</code><code style="color:#D1949E;">item.views</code><code style="color:rgba(255,255,255,0.7)">} views</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#ECEC93;">Text</code><code style="color:#D1949E;">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#ECEC93;">View</code><code style="color:#D1949E;">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#ECEC93;">Pressable</code><code style="color:#D1949E;">&gt;</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">)}</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">/&gt;</code><br>
&#xA0;&#xA0;<code style="color:#ffffff;">);</code><br>
<code style="color:#ffffff;">}</code><br>
<br>
<code style="color:#66A2CC;">const</code> styles <code style="color:#F5B83D;">=</code> <code style="color:#ffffff;">StyleSheet</code>.create<code style="color:rgba(255,255,255,0.7)">({</code><br>
&#xA0;&#xA0;<code style="color:#ffffff;">center</code>: <code style="color:rgba(255,255,255,0.7)">{</code> flex: <code style="color:#D1949E;">1</code>, justifyContent: <code style="color:#BDE052;">&quot;center&quot;</code>, alignItems: <code style="color:#BDE052;">&quot;center&quot;</code> <code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;list: <code style="color:rgba(255,255,255,0.7)">{</code> padding: <code style="color:#D1949E;">16</code> <code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;card: <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;flexDirection: <code style="color:#BDE052;">&quot;row&quot;</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;marginBottom: <code style="color:#D1949E;">16</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;backgroundColor: <code style="color:#BDE052;">&quot;#111&quot;</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;borderRadius: <code style="color:#D1949E;">10</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;overflow: <code style="color:#BDE052;">&quot;hidden&quot;</code>,<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;thumbnail: <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;width: <code style="color:#D1949E;">160</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;height: <code style="color:#D1949E;">90</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;backgroundColor: <code style="color:#BDE052;">&quot;#222&quot;</code>,<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;cardBody: <code style="color:rgba(255,255,255,0.7)">{</code><br>
&#xA0;&#xA0;&#xA0;&#xA0;flex: <code style="color:#D1949E;">1</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;padding: <code style="color:#D1949E;">10</code>,<br>
&#xA0;&#xA0;&#xA0;&#xA0;justifyContent: <code style="color:#BDE052;">&quot;center&quot;</code>,<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;title: <code style="color:rgba(255,255,255,0.7)">{</code> color: <code style="color:#BDE052;">&quot;#fff&quot;</code>, fontSize: <code style="color:#D1949E;">15</code>, fontWeight: <code style="color:#BDE052;">&quot;600&quot;</code> <code style="color:rgba(255,255,255,0.7)">},</code><br>
&#xA0;&#xA0;meta: <code style="color:rgba(255,255,255,0.7)">{</code> color: <code style="color:#BDE052;">&quot;#777&quot;</code>, fontSize: <code style="color:#D1949E;">12</code>, marginTop: <code style="color:#D1949E;">4</code> <code style="color:rgba(255,255,255,0.7)">},</code><br>
<code style="color:rgba(255,255,255,0.7)">});</code>
</div>
<br>
<!--kg-card-end: html-->
<p>This keeps the list simple, but it already feels like a real app: fast thumbnails, clean navigation, and no custom backend needed beyond the Bunny Stream API.</p><h3 id="playing-a-video-natively">Playing a video natively</h3><p>Now for the part that makes this approach worth using.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bunny-Smart-Chapters.png" class="kg-image" alt="How to add native video playback to your Expo app with Bunny Stream" loading="lazy" width="1094" height="1209" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bunny-Smart-Chapters.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bunny-Smart-Chapters.png 1000w, https://bunny.net/blog/content/images/2026/04/Bunny-Smart-Chapters.png 1094w" sizes="(min-width: 720px) 720px"></figure>
<!--kg-card-begin: html-->
<p>
  On the detail screen, we fetch the video metadata from Bunny Stream and hand the
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">HLS URL</code>
  to
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>.
  Because playback is native, the player can integrate with the OS in ways a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>
  cannot.
</p>

<p>
  We also use
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">useWindowDimensions</code>
  for explicit sizing,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">useEvent</code>
  for reactive playback state, and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">useEventListener</code>
  to track progress while the video is playing.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#998066;">// app/video/[id].tsx</code>
<br>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> useEffect, useState <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;react&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> View, Text, Pressable, ScrollView, StyleSheet, useWindowDimensions <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;react-native&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> useEvent, useEventListener <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> useVideoPlayer, <code style="color:#ECEC93;">VideoView</code>, <code style="color:#66A2CC;">type</code> <code style="color:#DBDD91;">VideoSource</code> <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo-video&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> useLocalSearchParams <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo-router&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;getVideo,
<br>
&#xA0;&#xA0;hlsUrl,
<br>
&#xA0;&#xA0;thumbnailUrl,
<br>
&#xA0;&#xA0;formatTime,
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">type</code> <code style="color:#DBDD91;">BunnyVideo</code> <code style="color:#66A2CC;">as</code> BunnyVideoMeta,
<br>
<code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;../../lib/bunny&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> ChapterList <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;../../components/ChapterList&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> CaptionPicker <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;../../components/CaptionPicker&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
<code style="color:#66A2CC;">export default function</code> <code style="color:#DBDD91;">VideoScreen</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:rgba(255,255,255,0.7)">{</code> id: videoId <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#F5B83D;">=</code> <code style="color:#DBDD91;">useLocalSearchParams&lt;{ id: string }&gt;</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:rgba(255,255,255,0.7)">{</code> width <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#F5B83D;">=</code> <code style="color:#DBDD91;">useWindowDimensions</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:rgba(255,255,255,0.7)">[</code>meta, setMeta<code style="color:rgba(255,255,255,0.7)">]</code> <code style="color:#F5B83D;">=</code> <code style="color:#DBDD91;">useState&lt;BunnyVideoMeta | null&gt;</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#66A2CC;">null</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#DBDD91;">useEffect</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#DBDD91;">getVideo</code><code style="color:rgba(255,255,255,0.7)">(</code>videoId<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:#F5B83D;">.</code><code style="color:#DBDD91;">then</code><code style="color:rgba(255,255,255,0.7)">(</code>setMeta<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:#F5B83D;">.</code><code style="color:#DBDD91;">catch</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#BDE052;">console</code>.error<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code>, <code style="color:rgba(255,255,255,0.7)">[</code>videoId<code style="color:rgba(255,255,255,0.7)">]</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> source<code style="color:#F5B83D;">:</code> VideoSource <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;uri<code style="color:#F5B83D;">:</code> <code style="color:#DBDD91;">hlsUrl</code><code style="color:rgba(255,255,255,0.7)">(</code>videoId<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;contentType<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;hls&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;metadata<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;title<code style="color:#F5B83D;">:</code> meta<code style="color:#F5B83D;">?</code><code style="color:#F5B83D;">.</code>title <code style="color:#F5B83D;">?</code><code style="color:#F5B83D;">?</code> <code style="color:#BDE052;">&quot;Loading&#x2026;&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;artwork<code style="color:#F5B83D;">:</code> meta<code style="color:#F5B83D;">?</code><code style="color:#F5B83D;">.</code>thumbnailFileName
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#F5B83D;">?</code> <code style="color:#DBDD91;">thumbnailUrl</code><code style="color:rgba(255,255,255,0.7)">(</code>videoId, meta.thumbnailFileName<code style="color:rgba(255,255,255,0.7)">)</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#F5B83D;">:</code> <code style="color:#66A2CC;">undefined</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> player <code style="color:#F5B83D;">=</code> <code style="color:#DBDD91;">useVideoPlayer</code><code style="color:rgba(255,255,255,0.7)">(</code>source, <code style="color:rgba(255,255,255,0.7)">(</code>p<code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;p.timeUpdateEventInterval <code style="color:#F5B83D;">=</code> <code style="color:#D1949E;">1</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;p.staysActiveInBackground <code style="color:#F5B83D;">=</code> <code style="color:#D1949E;">true</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;p.showNowPlayingNotification <code style="color:#F5B83D;">=</code> <code style="color:#D1949E;">true</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:rgba(255,255,255,0.7)">{</code> isPlaying <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#F5B83D;">=</code> <code style="color:#DBDD91;">useEvent</code><code style="color:rgba(255,255,255,0.7)">(</code>player, <code style="color:#BDE052;">&quot;playingChange&quot;</code>, <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;isPlaying<code style="color:#F5B83D;">:</code> player.playing<code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#998066;">// Progress tracking: save to your backend here</code>
<br>
&#xA0;&#xA0;<code style="color:#DBDD91;">useEventListener</code><code style="color:rgba(255,255,255,0.7)">(</code>player, <code style="color:#DBDD91;">&quot;timeUpdate&quot;</code>, <code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">{</code> currentTime <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#998066;">// e.g. saveProgress(videoId, currentTime, player.duration)</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">return</code> <code style="color:rgba(255,255,255,0.7)">(</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">ScrollView</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.screen}&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#998066;">{/* Video player */}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">VideoView</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">player</code><code style="color:#D1949E;">={player}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">style</code><code style="color:#D1949E;">={{ width, height: width * (9 / 16) }}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">contentFit</code><code style="color:#66A2CC;">=&quot;contain&quot;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">allowsPictureInPicture</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">fullscreenOptions</code><code style="color:#D1949E;">={{ enable: true }}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">/&gt;</code>
<br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#998066;">{/* Info */}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code>meta <code style="color:#F5B83D;">&amp;&amp;</code> <code style="color:rgba(255,255,255,0.7)">(</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">View</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.info}&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.title}&gt;</code><code style="color:rgba(255,255,255,0.7)">{</code>meta.title<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code>meta.description <code style="color:#F5B83D;">&amp;&amp;</code> <code style="color:rgba(255,255,255,0.7)">(</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.desc}&gt;</code><code style="color:rgba(255,255,255,0.7)">{</code>meta.description<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.meta}&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code><code style="color:#DBDD91;">formatTime</code><code style="color:rgba(255,255,255,0.7)">(</code>meta.length<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#F5B83D;">&#xB7;</code> <code style="color:rgba(255,255,255,0.7)">{</code>meta.width<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#F5B83D;">&#xD7;</code><code style="color:rgba(255,255,255,0.7)">{</code>meta.height<code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#F5B83D;">&#xB7;</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:#BDE052;">&quot; &quot;</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code>meta.availableResolutions <code style="color:#F5B83D;">?</code><code style="color:#F5B83D;">?</code> <code style="color:#BDE052;">&quot;processing&quot;</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">View</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#998066;">{/* Play / Pause */}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Pressable</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.playBtn}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">onPress</code><code style="color:#D1949E;">={() =&gt; (</code><code style="color:#D1949E;">isPlaying</code> <code style="color:#F5B83D;">?</code> <code style="color:#D1949E;">player.</code><code style="color:#DBDD91;">pause</code><code style="color:#D1949E;">()</code> <code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">player.</code><code style="color:#DBDD91;">play</code><code style="color:#D1949E;">())}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.playBtnText}&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code>isPlaying <code style="color:#F5B83D;">?</code> <code style="color:#BDE052;">&quot;Pause&quot;</code> <code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;Play&quot;</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Pressable</code><code style="color:#D1949E;">&gt;</code>
<br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#998066;">{/* Captions */}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">CaptionPicker</code> <code style="color:#BDE052;">player</code><code style="color:#D1949E;">={player} /&gt;</code>
<br>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#998066;">{/* Chapters */}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code>meta<code style="color:#F5B83D;">?</code><code style="color:#F5B83D;">.</code>chapters <code style="color:#F5B83D;">&amp;&amp;</code> meta.chapters.length <code style="color:#F5B83D;">&gt;</code> <code style="color:#DAB14B;">0</code> <code style="color:#F5B83D;">&amp;&amp;</code> <code style="color:rgba(255,255,255,0.7)">(</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">View</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.section}&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.sectionTitle}&gt;</code>Chapters<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">ChapterList</code> <code style="color:#BDE052;">chapters</code><code style="color:#D1949E;">={meta.chapters}</code> <code style="color:#BDE052;">player</code><code style="color:#D1949E;">={player} /&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">View</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">ScrollView</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code>
<br>
<br>
<code style="color:#66A2CC;">const</code> styles <code style="color:#F5B83D;">=</code> StyleSheet<code style="color:#F5B83D;">.</code><code style="color:#DBDD91;">create</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;screen<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> flex<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">1</code>, backgroundColor<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#0a0a0a&quot;</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;info<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> padding<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">16</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;title<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#fff&quot;</code>, fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">18</code>, fontWeight<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;700&quot;</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;desc<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#999&quot;</code>, fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">14</code>, marginTop<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">4</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;meta<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#666&quot;</code>, fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">12</code>, marginTop<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">4</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;playBtn<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;alignSelf<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;center&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;backgroundColor<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#FF6B00&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;paddingHorizontal<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">32</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;paddingVertical<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">12</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;borderRadius<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">8</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;marginVertical<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">12</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;playBtnText<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#fff&quot;</code>, fontWeight<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;600&quot;</code>, fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">16</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;section<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> paddingHorizontal<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">16</code>, marginTop<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">12</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;sectionTitle<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#aaa&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">14</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;fontWeight<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;600&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;marginBottom<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">8</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>

</div>
<!--kg-card-end: html-->
<p>This is the tradeoff in practice.</p><p>You lose some of the convenience <a href="https://bunny.net/stream/cdn-player/">Bunny Player</a> gives you automatically, but you gain real native playback behavior and full control over the experience.</p><h3 id="adding-chapter-navigation">Adding chapter navigation</h3>
<!--kg-card-begin: html-->
<p>
  Bunny Stream returns chapter data as an array of objects with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">title</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">start</code>, and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">end</code>
  values in seconds. Because
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  exposes a writable
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">currentTime</code>,
  chapter jumping is easy to implement.
</p>

<p>
  The same pattern also works for Bunny Stream
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">moments</code>,
  using
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">moment.timestamp</code>
  instead of
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">chapter.start</code>.
</p>
<!--kg-card-end: html-->
<figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bunny-Smart-Chapters2.png" class="kg-image" alt="How to add native video playback to your Expo app with Bunny Stream" loading="lazy" width="1094" height="257" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bunny-Smart-Chapters2.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bunny-Smart-Chapters2.png 1000w, https://bunny.net/blog/content/images/2026/04/Bunny-Smart-Chapters2.png 1094w" sizes="(min-width: 720px) 720px"></figure>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#998066;">// components/ChapterList.tsx</code>
<br>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#ffffff;">FlatList</code>, <code style="color:#ffffff;">Pressable</code>, <code style="color:#ffffff;">Text</code>, StyleSheet <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;react-native&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import type</code> <code style="color:rgba(255,255,255,0.7)">{</code> VideoPlayer <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo-video&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> formatTime, <code style="color:#66A2CC;">type</code> <code style="color:#DBDD91;">BunnyChapter</code> <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;../lib/bunny&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
<code style="color:#66A2CC;">type</code> <code style="color:#DBDD91;">ChapterListProps</code> <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;chapters<code style="color:#F5B83D;">:</code> BunnyChapter<code style="color:rgba(255,255,255,0.7)">[</code><code style="color:rgba(255,255,255,0.7)">]</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;player<code style="color:#F5B83D;">:</code> VideoPlayer<code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
<code style="color:#66A2CC;">export function</code> <code style="color:#DBDD91;">ChapterList</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">{</code> chapters, player <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#F5B83D;">:</code> ChapterListProps<code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">return</code> <code style="color:rgba(255,255,255,0.7)">(</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">FlatList</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">horizontal</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">data</code><code style="color:#D1949E;">={chapters}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">keyExtractor</code><code style="color:#D1949E;">={(c) =&gt; </code><code style="color:#DBDD91;">String</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#D1949E;">c.start</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:#D1949E;">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">showsHorizontalScrollIndicator</code><code style="color:#D1949E;">={false}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">renderItem</code><code style="color:#D1949E;">={({ item }) =&gt; (</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Pressable</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.chip}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">onPress</code><code style="color:#D1949E;">={() =&gt; {</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">player.currentTime</code> <code style="color:#F5B83D;">=</code> <code style="color:#D1949E;">item.start</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#66A2CC;">if</code> <code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#F5B83D;">!</code><code style="color:#D1949E;">player.playing)</code> <code style="color:#D1949E;">player.</code><code style="color:#DBDD91;">play</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">}}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.chipTime}&gt;</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:#DBDD91;">formatTime</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#D1949E;">item.start</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">={styles.chipTitle}&gt;</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:#D1949E;">item.title</code><code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Pressable</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">)}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">/&gt;</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code>
<br>
<br>
<code style="color:#66A2CC;">const</code> styles <code style="color:#F5B83D;">=</code> StyleSheet<code style="color:#F5B83D;">.</code><code style="color:#DBDD91;">create</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;chip<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;backgroundColor<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#1a1a1a&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;borderRadius<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">8</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;paddingHorizontal<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">14</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;paddingVertical<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">10</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;marginRight<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">8</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;chipTime<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#FF6B00&quot;</code>, fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">12</code>, fontWeight<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;600&quot;</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;chipTitle<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#ddd&quot;</code>, fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">13</code>, marginTop<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">2</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>

</div>
<br>
<!--kg-card-end: html-->
<figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/04/Bunny-Smart-Chapters-1.png" class="kg-image" alt="How to add native video playback to your Expo app with Bunny Stream" loading="lazy" width="1094" height="1209" srcset="https://bunny.net/blog/content/images/size/w600/2026/04/Bunny-Smart-Chapters-1.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/04/Bunny-Smart-Chapters-1.png 1000w, https://bunny.net/blog/content/images/2026/04/Bunny-Smart-Chapters-1.png 1094w" sizes="(min-width: 720px) 720px"></figure><h3 id="wiring-up-captions">Wiring up captions</h3>
<!--kg-card-begin: html-->
<p>
  Bunny Stream includes subtitle tracks in the HLS manifest. After the player source loads,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  exposes
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">availableSubtitleTracks</code>
  and lets you set
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">subtitleTrack</code>
  directly.
</p>

<p>
  That means you can build a simple caption selector with native track switching and no
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>
  bridge in the middle.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#998066;">// components/CaptionPicker.tsx</code>
<br>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> useState <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;react&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#ffffff;">Pressable</code>, <code style="color:#ffffff;">Text</code>, <code style="color:#ffffff;">View</code>, StyleSheet <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;react-native&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> useEventListener <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:#66A2CC;">import</code> <code style="color:rgba(255,255,255,0.7)">{</code> <code style="color:#66A2CC;">type</code> <code style="color:#DBDD91;">VideoPlayer</code>, <code style="color:#66A2CC;">type</code> <code style="color:#DBDD91;">SubtitleTrack</code> <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#66A2CC;">from</code> <code style="color:#BDE052;">&quot;expo-video&quot;</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
<code style="color:#66A2CC;">type</code> <code style="color:#DBDD91;">CaptionPickerProps</code> <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;player<code style="color:#F5B83D;">:</code> VideoPlayer<code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
<code style="color:#66A2CC;">export function</code> <code style="color:#DBDD91;">CaptionPicker</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">{</code> player <code style="color:rgba(255,255,255,0.7)">}</code> <code style="color:#F5B83D;">:</code> CaptionPickerProps<code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:rgba(255,255,255,0.7)">[</code>tracks, setTracks<code style="color:rgba(255,255,255,0.7)">]</code> <code style="color:#F5B83D;">=</code> <code style="color:#DBDD91;">useState&lt;SubtitleTrack[]&gt;</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">[</code><code style="color:rgba(255,255,255,0.7)">]</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> <code style="color:rgba(255,255,255,0.7)">[</code>active, setActive<code style="color:rgba(255,255,255,0.7)">]</code> <code style="color:#F5B83D;">=</code> <code style="color:#DBDD91;">useState&lt;SubtitleTrack | null&gt;</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#66A2CC;">null</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#DBDD91;">useEventListener</code><code style="color:rgba(255,255,255,0.7)">(</code>player, <code style="color:#BDE052;">&quot;sourceLoad&quot;</code>, <code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">{</code> availableSubtitleTracks <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;setTracks<code style="color:rgba(255,255,255,0.7)">(</code>availableSubtitleTracks<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">const</code> select <code style="color:#F5B83D;">=</code> <code style="color:rgba(255,255,255,0.7)">(</code>track<code style="color:#F5B83D;">:</code> SubtitleTrack | <code style="color:#66A2CC;">null</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;player.subtitleTrack <code style="color:#F5B83D;">=</code> track<code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;setActive<code style="color:rgba(255,255,255,0.7)">(</code>track<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">if</code> <code style="color:rgba(255,255,255,0.7)">(</code>tracks.length <code style="color:#F5B83D;">===</code> <code style="color:#D1949E;">0</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#66A2CC;">return</code> <code style="color:#66A2CC;">null</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<br>
&#xA0;&#xA0;<code style="color:#66A2CC;">return</code> <code style="color:rgba(255,255,255,0.7)">(</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">View</code> <code style="color:#BDE052;">style</code><code style="color:#D1949E;">=</code><code style="color:rgba(255,255,255,0.7)">{</code>styles.row<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Pressable</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">style</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:rgba(255,255,255,0.7)">[</code>styles.chip, <code style="color:#F5B83D;">!</code>active <code style="color:#F5B83D;">&amp;&amp;</code> styles.chipActive<code style="color:rgba(255,255,255,0.7)">]</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">onPress</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> select<code style="color:rgba(255,255,255,0.7)">(</code><code style="color:#66A2CC;">null</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code>styles.chipText<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&gt;</code>Off<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Pressable</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">{</code>tracks.map<code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">(</code>t<code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> <code style="color:rgba(255,255,255,0.7)">(</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Pressable</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">key</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code>t.language<code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">style</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:rgba(255,255,255,0.7)">[</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;styles.chip<code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;active<code style="color:#F5B83D;">?</code><code style="color:#F5B83D;">.</code>language <code style="color:#F5B83D;">===</code> t.language <code style="color:#F5B83D;">&amp;&amp;</code> styles.chipActive<code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">]</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#BDE052;">onPress</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">)</code> <code style="color:#F5B83D;">=&gt;</code> select<code style="color:rgba(255,255,255,0.7)">(</code>t<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;</code><code style="color:#DBDD91;">Text</code> <code style="color:#BDE052;">style</code><code style="color:#F5B83D;">=</code><code style="color:rgba(255,255,255,0.7)">{</code>styles.chipText<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&gt;</code><code style="color:rgba(255,255,255,0.7)">{</code>t.label<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Text</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">Pressable</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">))}</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;<code style="color:#D1949E;">&lt;/</code><code style="color:#DBDD91;">View</code><code style="color:#D1949E;">&gt;</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code>
<br>
<br>
<code style="color:#66A2CC;">const</code> styles <code style="color:#F5B83D;">=</code> StyleSheet<code style="color:#F5B83D;">.</code><code style="color:#DBDD91;">create</code><code style="color:rgba(255,255,255,0.7)">(</code><code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;row<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;flexDirection<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;row&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;gap<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">8</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;paddingHorizontal<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">16</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;marginBottom<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">12</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;chip<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;backgroundColor<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#1a1a1a&quot;</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;borderRadius<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">6</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;paddingHorizontal<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">12</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;&#xA0;&#xA0;paddingVertical<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">6</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;chipActive<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> backgroundColor<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#FF6B00&quot;</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
&#xA0;&#xA0;chipText<code style="color:#F5B83D;">:</code> <code style="color:rgba(255,255,255,0.7)">{</code> color<code style="color:#F5B83D;">:</code> <code style="color:#BDE052;">&quot;#fff&quot;</code>, fontSize<code style="color:#F5B83D;">:</code> <code style="color:#D1949E;">13</code> <code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">,</code>
<br>
<code style="color:rgba(255,255,255,0.7)">}</code><code style="color:rgba(255,255,255,0.7)">)</code><code style="color:rgba(255,255,255,0.7)">;</code>

</div>
<!--kg-card-end: html-->
<h3 id="project-structure">Project structure</h3><p>Here is the final structure for the example app:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

src/
<br>
&#x251C;&#x2500;&#x2500; app/
<br>
&#x2502;&#xA0;&#xA0;&#xA0;&#x251C;&#x2500;&#x2500; _layout.tsx&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;# Root Stack navigator
<br>
&#x2502;&#xA0;&#xA0;&#xA0;&#x251C;&#x2500;&#x2500; index.tsx&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;# Video list: fetches library, navigates to detail
<br>
&#x2502;&#xA0;&#xA0;&#xA0;&#x2514;&#x2500;&#x2500; video/
<br>
&#x2502;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2514;&#x2500;&#x2500; [id].tsx&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;# Video detail: native HLS playback with metadata
<br>
&#x251C;&#x2500;&#x2500; components/
<br>
&#x2502;&#xA0;&#xA0;&#xA0;&#x251C;&#x2500;&#x2500; ChapterList.tsx&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;# Tappable chapter chips
<br>
&#x2502;&#xA0;&#xA0;&#xA0;&#x2514;&#x2500;&#x2500; CaptionPicker.tsx&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;# Subtitle track picker
<br>
&#x2514;&#x2500;&#x2500; lib/
<br>
&#xA0;&#xA0;&#xA0;&#xA0;&#x2514;&#x2500;&#x2500; bunny.ts&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;# Types, API client, URL helpers

</div>
<br>
<!--kg-card-end: html-->
<p>It is a small project, but it covers the pieces most real apps need: browse videos, open a detail page, play natively, expose captions, jump between chapters, and track playback progress.</p><p>You can find the full <a href="https://github.com/jamie-at-bunny/bunny-stream-expo">source code</a> of the demo app on GitHub.</p><h3 id="choosing-the-right-playback-approach">Choosing the right playback approach</h3><p>bunny.net gives you a few different ways to play Bunny Stream videos, and the right choice depends on how much control you need.</p>
<!--kg-card-begin: html-->
<p>
  <a href="https://docs.bunny.net/docs/stream-embedding-videos">Bunny Player</a>
  is still the fastest path. If you want to ship quickly and do not need deep native media integration, embedding it in a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>
  lets you drop in a complete player without building controls or playback logic.
</p>

<p>
  <a href="https://github.com/BunnyWay/bunny-stream-ios"><strong>Bunny Stream iOS SDK</strong></a>
  and
  <a href="https://github.com/BunnyWay/bunny-stream-android"><strong>Bunny Stream Android SDK</strong></a>
  are the native-first option. They include built-in analytics, dashboard-controlled branding, TUS upload support, and camera capture, but they are designed for Swift and Kotlin apps rather than Expo&#x2019;s managed workflow.
</p>

<p>
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  + Bunny Stream API sits in the middle. You keep Expo compatibility, gain access to native playback behavior, and build your own UI around Bunny Stream&#x2019;s HLS playback and metadata APIs.
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<table style="border:1px solid rgba(24, 61, 109, 0.18); border-collapse:collapse; width:100%;">
  <thead>
    <tr style="font-weight:700; text-align:left; background:#223c6a; color:white;">
      <th style="padding:12px 14px; background:rgba(255,255,255,0.03); border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Feature
      </th>
      <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Bunny Player (<code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>)
      </th>
      <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); color:white;">
        Bunny Stream native SDKs
      </th>
      <th style="padding:12px 14px; border-bottom:1px solid rgba(24, 61, 109, 0.18); color:white;">
        <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code> + Bunny Stream API
      </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Expo compatible</td>
      <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes (<code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>)</td>
      <td style="padding:12px 14px; border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">No (Swift/Kotlin only)</td>
      <td style="padding:12px 14px; vertical-align:middle; color:#183d6d;">Yes (development build)</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Setup complexity</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Embed URL in a <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code></td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">SPM or Maven dependency</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Development build required</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Native controls</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">No (web controls in <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>)</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Picture-in-Picture</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;"><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code> cannot surface to OS</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Background audio</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;"><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code> cannot surface to OS</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Lock screen / now playing</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;"><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code> cannot surface to OS</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Programmatic playback control</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Via postMessage bridge</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Direct API on player instance</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Direct API on player instance</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Progress tracking</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Via Player.js in <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code></td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Built in</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Native <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">timeUpdate</code> event</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Chapters / moments UI</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Built in</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Dashboard controlled</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Build your own from API data</td>
    </tr>
    <tr>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Built-in analytics</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); border-right:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">Yes</td>
      <td style="padding:12px 14px; border-top:1px solid rgba(24, 61, 109, 0.18); vertical-align:middle; color:#183d6d;">No (roll your own)</td>
    </tr>
  </tbody>
</table>
<!--kg-card-end: html-->
<h2 id="which-one-should-you-use">Which one should you use?</h2><p>If all you need is reliable video playback inside an Expo app, start with <a href="https://docs.bunny.net/stream/player">Bunny Player</a>.</p><p>It is faster to integrate, easier to maintain, and for many use cases, it is more than enough.</p>
<!--kg-card-begin: html-->
<p>
  But when your app needs to behave like a real native media app, with Picture-in-Picture, background playback, lock screen controls, and tighter control over the playback experience, native playback is the better fit.
</p>

<p>
  That is where
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  and <a href="https://bunny.net/stream/">Bunny Stream</a> work well together.
</p>

<p>
  Bunny Stream gives you the delivery, metadata, captions, chapters, and adaptive HLS playback.
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">expo-video</code>
  gives you native player access inside Expo. Together, they let you build a video experience that feels fully at home on the platform instead of living inside a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">WebView</code>.
</p>
<!--kg-card-end: html-->
]]></content:encoded></item><item><title><![CDATA[JA4 fingerprinting: a better way to identify clients]]></title><description><![CDATA[JA3 was one of the first widely adopted approaches here. It takes values from the TLS handshake and hashes them into a fingerprint, giving you a way to group similar clients together.]]></description><link>https://bunny.net/blog/ja4-fingerprinting-a-better-way-to-identify-clients/</link><guid isPermaLink="false">69c3b9ea160dc403fbfcf017</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Joe Connolly]]></dc:creator><pubDate>Wed, 25 Mar 2026 11:00:05 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/03/bunny-JA4-fingerprinting.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/03/bunny-JA4-fingerprinting.png" alt="JA4 fingerprinting: a better way to identify clients"><p>There was a time when identifying traffic on the internet was relatively straightforward.</p><p>An IP address and a User-Agent were usually enough to make a decision. If something looked wrong, you blocked it. Otherwise, you let it through.</p><p>That approach no longer holds up.</p><p>Today, a single automated client can appear as thousands of different users, rotating IPs, mutating headers, and mimicking real browsers with surprising accuracy. Botnets operate at scale across large networks, while headless frameworks and AI-driven agents make it even easier to blend in.</p><p>At that point, what a client claims to be is no longer something you can rely on. You need a signal that is much harder to fake.</p><h3 id="looking-at-what-clients-can%E2%80%99t-easily-fake">Looking at what clients can&#x2019;t easily fake</h3>
<!--kg-card-begin: html-->
<p>
  Every HTTPS connection starts with a TLS handshake. Before any request is sent, the client introduces itself through a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">ClientHello</code>
  message that reflects its underlying TLS stack, including supported versions, cipher suites, extensions, and protocol preferences.
</p>
<!--kg-card-end: html-->
<p>Unlike headers, which are trivially manipulated, the TLS handshake reflects the client&#x2019;s underlying implementation and is far harder to replicate.</p><p>JA3 was one of the first widely adopted approaches here. It takes values from the TLS handshake and hashes them into a fingerprint, giving you a way to group similar clients together.</p><p>For a while, it worked well. However, it relies heavily on the exact ordering of fields in the handshake, which introduces a key limitation.</p><p>Modern browsers and privacy-focused clients often shuffle TLS extensions specifically to avoid being tracked. From a functionality point of view, nothing has changed, but to JA3 it looks like a completely different client.</p><p>The same browser can produce multiple fingerprints, making it harder to reason about. At the same time, attackers can exploit this by slightly tweaking the ordering or configuration to avoid matching known fingerprints.</p><p>JA4 addresses this directly by removing those inconsistencies.</p><p>Instead of hashing the raw handshake as-is, it normalizes the data first. It focuses on the parts that actually describe the client and removes noise like ordering differences.</p><p>The result is a fingerprint that stays consistent for the same client, even with those small variations, and is much harder to manipulate without actually changing the underlying TLS stack.</p><h3 id="a-more-stable-way-to-fingerprint-clients">A more stable way to fingerprint clients</h3><p>A JA4 fingerprint is a compact string like this:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

t13d1516h2_8daaf6152771_02713d6af862

</div>
<!--kg-card-end: html-->
<p>At first glance it looks cryptic, but it is not random.</p><p>It is made up of multiple parts, each describing a different aspect of the TLS handshake.</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/03/bunny.net-JA4-explanation.png" class="kg-image" alt="JA4 fingerprinting: a better way to identify clients" loading="lazy" width="2000" height="969" srcset="https://bunny.net/blog/content/images/size/w600/2026/03/bunny.net-JA4-explanation.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/03/bunny.net-JA4-explanation.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/03/bunny.net-JA4-explanation.png 1600w, https://bunny.net/blog/content/images/size/w2400/2026/03/bunny.net-JA4-explanation.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>Once you break it down, it becomes much easier to reason about. You are not just looking at a hash; you are looking at a structured summary of how that client speaks TLS.</p><p>That structure is what makes it practical.</p><p>Instead of treating the fingerprint as a single opaque value, you can match on parts of it, group similar clients together, or spot outliers that do not fit expected patterns.</p><p>And because those parts are derived from the client&#x2019;s actual implementation, they tend to stay stable even when everything around them changes.</p><p>JA4 is not meant to uniquely identify individual users. It is a signal that helps group similar clients and works best when combined with other data like IP addresses, request headers, and behavior.</p><h3 id="ja4-built-into-bunnynet">JA4, built into bunny.net</h3><p>We&apos;ve incorporated JA4 into our Layer 7 DDoS mitigation and bot detection systems to group related activity and make more accurate decisions without relying on easily spoofed signals.</p>
<!--kg-card-begin: html-->
<p>
  And since JA4 fingerprinting is now computed automatically for every HTTPS request passing through bunny.net, each request is assigned a fingerprint at the edge and forwarded to your origin via the <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">CDN-JA4</code> header.
</p>
<!--kg-card-end: html-->
<p>That gives you direct access to the same signal we use internally, with full details available in the <a href="https://docs.bunny.net/cdn/security/ja4-fingerprinting">JA4 fingerprinting documentation</a>.</p><p>Because the fingerprint reflects the client&#x2019;s actual TLS implementation, it stays consistent even when other identifiers change. That makes it particularly useful for tracking automated traffic that rotates IPs or mutates headers between requests.</p><p>For example, traffic coming from hundreds or thousands of different IP addresses may still share the same JA4 fingerprint, letting you group and act on it as a single source.</p><h3 id="putting-ja4-to-work-with-bunny-shield">Putting JA4 to work with Bunny Shield</h3><p>JA4 is most useful when you can act on it directly.</p><p>We have integrated JA4 deeply into Bunny Shield so you can use it to improve your security posture at the edge.</p><p>You can create rate limits based on JA4, or combine it with IP addresses for tighter control. You can write custom WAF rules that match a full fingerprint or even specific parts of it. You can also build access lists around known JA4 identities to block, allow, or challenge specific clients.</p><p>This is especially useful when dealing with distributed traffic. Even if requests are spread across many IPs, they often still share the same underlying fingerprint. Instead of chasing individual requests, you can act on the source implementation itself.</p><p>The end result is simple. You are no longer limited to what a client claims to be. You can make decisions based on how it is actually built.</p><h3 id="a-better-signal-for-a-noisier-internet">A better signal for a noisier internet</h3><p>Relying on IPs and headers is becoming less effective as traffic becomes more distributed and easier to disguise.</p><p>JA4 gives you a more stable way to understand what is behind each request. Instead of chasing constantly changing surface-level signals, you can start grouping and acting on traffic based on how it is implemented.</p><p>It&#x2019;s already available on every request passing through bunny.net and fully integrated into Bunny Shield, so you can put it to use immediately.</p><p>If you want a clearer view of your traffic or tighter control over how it behaves, this is a good place to start.</p><p><a href="https://dash.bunny.net/auth/login">Log in</a> to explore JA4, or <a href="https://dash.bunny.net/auth/register">sign up</a> to get started in minutes.</p>]]></content:encoded></item><item><title><![CDATA[Introducing pattern matching for Edge Rules]]></title><description><![CDATA[If you're already familiar with Lua patterns, everything behaves exactly as you would expect. If not, the examples below should give you a good feel for how they can be used in practice.]]></description><link>https://bunny.net/blog/introducing-pattern-matching-for-edge-rules/</link><guid isPermaLink="false">69ba99ff160dc403fbfcefd7</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Joe Connolly]]></dc:creator><pubDate>Thu, 19 Mar 2026 09:00:00 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/03/Bunny-Pattern-Matching.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/03/Bunny-Pattern-Matching.png" alt="Introducing pattern matching for Edge Rules"><p>Edge Rules allow you to define logic that runs directly on bunny.net&#x2019;s edge servers before a request reaches your origin. They can modify caching behavior, redirect traffic, route requests to different origins, apply security actions, and much more based on conditions like the request URL, headers, query string, or hostname.</p><p>A common part of writing Edge Rules is matching requests based on these values. In many cases, a simple string or wildcard match is enough, but modern applications often generate URLs that follow predictable patterns rather than exact values. Streaming manifests, versioned build assets, and dynamically generated API routes are all good examples.</p><p>To make these scenarios easier to handle, we&#x2019;ve added pattern matching support to Edge Rule conditions.</p><h2 id="using-pattern-matching-in-edge-rules">Using pattern matching in Edge Rules</h2><p>Pattern matching can be used in any Edge Rule condition that evaluates a value, such as the request URL, headers, or query string.</p><p>To indicate that a condition should use pattern matching, write the value using the following format:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#C6979E;">pattern</code><code style="color:#E9BC4F;">:^...</code><code style="color:#ffffff;">$</code>

</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  The
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">pattern:</code>
  prefix tells the Edge Rule engine to evaluate the value using Lua&#x2019;s pattern matching instead of a normal string comparison. Lua patterns are a lightweight pattern-matching system similar to regular expressions, but intentionally simpler and faster.
</p>

<p>
  The pattern itself should be wrapped with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">^</code>
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">$</code>
  so the entire value is matched. If the prefix is not present, the condition behaves as a normal string comparison.
</p>

<p>For example:</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#C6979E;">pattern</code><code style="color:#E9BC4F;">:^.*</code>/video_chunk<code style="color:#E9BC4F;">%-</code>[<code style="color:#E9BC4F;">^</code><code style="color:#E9BC4F;">%-</code>]<code style="color:#E9BC4F;">+</code><code style="color:#E9BC4F;">%-</code>[<code style="color:#E9BC4F;">^</code><code style="color:#E9BC4F;">%-</code>]<code style="color:#E9BC4F;">+</code><code style="color:#E9BC4F;">%</code>.dash$

</div>
<!--kg-card-end: html-->
<p>This pattern matches URLs such as:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#E9BC4F;">/</code>live<code style="color:#E9BC4F;">/</code>video_chunk<code style="color:#E9BC4F;">-</code>us<code style="color:#E9BC4F;">-</code><code style="color:#C6979E;">12345</code>.dash
<br>
<code style="color:#E9BC4F;">/</code>live<code style="color:#E9BC4F;">/</code>video_chunk<code style="color:#E9BC4F;">-</code>es<code style="color:#E9BC4F;">-</code><code style="color:#C6979E;">54321</code>.dash

</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Internally, the match is evaluated using Lua&#x2019;s
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">string.find</code>
  function, which performs pattern matching without requiring a full regular expression engine while still supporting useful pattern features such as character classes, repetition operators, and escaped characters.
</p>
<!--kg-card-end: html-->
<p>If you&apos;re already familiar with Lua patterns, everything behaves exactly as you would expect. If not, the examples below should give you a good feel for how they can be used in practice.</p><h2 id="pattern-matching-in-action">Pattern matching in action</h2><p>With pattern matching available, many common CDN scenarios become easier to express with a single condition.</p><p>Below are a few examples where this simplifies Edge Rules while adding a great deal of flexibility.</p><h3 id="matching-streaming-manifests">Matching streaming manifests</h3><p>Streaming platforms often generate manifest files that include region identifiers, channel names, or session information in the filename, such as:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

/live/video_chunk-us-12345.dash
<br>
/live/video_chunk-es-54321.dash

</div>
<!--kg-card-end: html-->
<p>A single condition can match these requests and, for example, apply custom cache control:</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/03/Custom-Cache-Control.png" class="kg-image" alt="Introducing pattern matching for Edge Rules" loading="lazy" width="1658" height="1297" srcset="https://bunny.net/blog/content/images/size/w600/2026/03/Custom-Cache-Control.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/03/Custom-Cache-Control.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/03/Custom-Cache-Control.png 1600w, https://bunny.net/blog/content/images/2026/03/Custom-Cache-Control.png 1658w" sizes="(min-width: 720px) 720px"></figure><h3 id="targeting-versioned-assets">Targeting versioned assets</h3><p>Many build systems generate static assets with version numbers in the filename.</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

/assets/app-1.4.7.js
<br>
/assets/app-1.4.8.js
<br>
/assets/app-1.5.0.js

</div>
<!--kg-card-end: html-->
<p>A single condition can match them and apply actions such as rate limiting:</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/03/Rate-Limiting.png" class="kg-image" alt="Introducing pattern matching for Edge Rules" loading="lazy" width="1655" height="1300" srcset="https://bunny.net/blog/content/images/size/w600/2026/03/Rate-Limiting.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/03/Rate-Limiting.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/03/Rate-Limiting.png 1600w, https://bunny.net/blog/content/images/2026/03/Rate-Limiting.png 1655w" sizes="(min-width: 720px) 720px"></figure><h3 id="blocking-suspicious-requests">Blocking suspicious requests</h3><p>Pattern matching is also useful for security rules.</p><p>For example, you might want to block requests targeting administrative PHP endpoints when the expected session cookie is missing:</p><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/03/expected-session-cookie.png" class="kg-image" alt="Introducing pattern matching for Edge Rules" loading="lazy" width="1652" height="1666" srcset="https://bunny.net/blog/content/images/size/w600/2026/03/expected-session-cookie.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/03/expected-session-cookie.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/03/expected-session-cookie.png 1600w, https://bunny.net/blog/content/images/2026/03/expected-session-cookie.png 1652w" sizes="(min-width: 720px) 720px"></figure>
<!--kg-card-begin: html-->
<h3>Pattern matching cheat sheet</h3>

<p>
  <strong>Format:</strong> Condition values must start with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">pattern:^</code>
  and end with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">$</code>
  (e.g.,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">pattern:^...$</code>).
  Edge Rules evaluate the expression with Lua&#x2019;s
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">string.find</code>.
</p>

<p>
  <strong>Anchors:</strong> Always use
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">^</code>
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">$</code>
  to match the entire value.
</p>

<p><strong>Common classes:</strong></p>
<ul>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">%d</code> digit</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">%a</code> letter</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">%w</code> alnum + underscore</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.</code> any character</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">[abc]</code> match any listed character</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">[^abc]</code> match any character not listed</li>
</ul>

<p><strong>Repeaters:</strong></p>
<ul>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">+</code> = 1 or more</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">*</code> = 0 or more (greedy)</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">-</code> = 0 or more (non-greedy)</li>
</ul>

<p>
  <strong>Escape:</strong> Use
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">%</code>
  to escape special chars, for example
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">%.</code>
  for a literal dot and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">%-</code>
  for a hyphen.
</p>

<p>
  <strong>Limitations:</strong> No
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">|</code>
  alternation, no lookaheads/lookbehinds, and no full PCRE. Patterns are intentionally small and fast.
</p>
<!--kg-card-end: html-->
<p>Full reference and examples: <a href="https://docs.bunny.net/cdn/edge-rules/pattern-matching" rel="noopener noreferrer">https://docs.bunny.net/cdn/edge-rules/pattern-matching</a></p><h2 id="why-lua-pattern-matching">Why Lua pattern matching?</h2><p>Lua patterns provide a good balance between expressive matching and predictable performance.</p><p>Unlike full regular expressions, Lua patterns are intentionally simpler and implemented directly in Lua&#x2019;s standard library. This avoids the overhead of a full regex engine while still supporting the most commonly needed matching features such as character classes, repetition operators, and captures.</p><p>For edge environments where rules are evaluated on every request, this simplicity is important. Pattern evaluation remains fast, deterministic, and lightweight, even under very high request volumes across the network.</p><p>Using Lua&#x2019;s native pattern matching also keeps the implementation compact and reliable across all edge nodes while still giving developers enough flexibility to match structured URLs, headers, and request values in practical scenarios.</p><h2 id="try-pattern-matching-in-edge-rules">Try pattern matching in Edge Rules</h2><p>Pattern matching is now available in Edge Rule conditions and can be used anywhere a request value is evaluated, including URLs, headers, cookies, and query strings.</p><p>This makes it possible to express much more precise request logic without creating multiple rules or pushing filtering back to the origin. In many cases, complex matching can now be handled with a single condition running directly at the edge.</p><p>If you&apos;re already using Edge Rules, try replacing some of your existing conditions with pattern matching and see how much simpler your rules can become.</p><p>If you haven&apos;t explored Edge Rules yet, this is a great place to start.</p><p><a href="https://dash.bunny.net/auth/login">Log in</a> or <a href="https://dash.bunny.net/auth/register">sign up</a> for a bunny.net account to create your first pattern matching Edge Rule.</p>]]></content:encoded></item><item><title><![CDATA[Introducing the interactive Bunny Database shell]]></title><description><![CDATA[The interactive Bunny Database shell is currently in public preview. While fully functional, we recommend exercising caution with production workloads as we continue refining the experience.]]></description><link>https://bunny.net/blog/introducing-the-interactive-bunny-database-shell/</link><guid isPermaLink="false">69b92c4b160dc403fbfcef56</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Jamie Barton]]></dc:creator><pubDate>Tue, 17 Mar 2026 14:45:00 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/03/Bunny-Database-Shell.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/03/Bunny-Database-Shell.png" alt="Introducing the interactive Bunny Database shell"><p>When we started building <a href="https://docs.bunny.net/database">Bunny Database</a>, we needed a shell-like experience. Naturally, we reached for <a href="https://github.com/libsql/libsql-shell-go">libsql-shell-go</a>, the official Go-based shell from the libSQL project. It&apos;s a solid tool, and it served us well in early development.</p><p>But as we began building the upcoming bunny.net CLI in TypeScript, maintaining a separate Go binary for just the shell felt like carrying two toolchains for one job. We wanted a shell that could be embedded directly into our CLI, share the same authentication flow, and ship as a single binary. So we rewrote it from scratch in TypeScript.</p>
<!--kg-card-begin: html-->
<p>
  The result is
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">@bunny.net/database-shell</code>
  &#x2014; a standalone, interactive SQL shell for libSQL databases that also powers
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db shell</code>
  inside the upcoming bunny.net CLI.
</p>
<!--kg-card-end: html-->
<p>The interactive Bunny Database shell is currently in public preview. While fully functional, we recommend exercising caution with production workloads as we continue refining the experience.</p><h2 id="connect-and-query">Connect and query</h2><p>You will need Node.js 18 or later installed and an existing database to continue. If you don&apos;t have one, <a href="http://docs.bunny.net/database/quickstart">create one</a>.</p><p>Navigate to <strong>Dashboard &gt; Edge Platform &gt; Database &gt; [Select Database] &gt; Access</strong> to find your database URL and generate an access token.</p>
<!--kg-card-begin: html-->
<p>
  Once you&#x2019;re ready, execute the
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">npx</code>
  command to connect to your database:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
npx @bunny.net/database-shell libsql://... --token ...
</div>
<!--kg-card-end: html-->
<p>You get a readline-powered REPL with multi-line SQL support, persistent command history, and query timing:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

&#x2713; Connected to database
<br>
&#xA0;&#xA0;Type .help <code style="color:#779FC9;">for</code> commands, .quit to exit.
<br>
<br>
&#x2192;&#xA0;&#xA0;SELECT <code style="color:#E1E395;">id</code>, name, email FROM <code style="color:#E1E395;">users</code>
<br>
&#xA0;&#xA0;&#xA0;WHERE created_at <code style="color:#E3B74D;">&gt;</code> <code style="color:#BDD95F;">&apos;2025-01-01&apos;</code>;
<br>
&#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;
<br>
&#x2502; <code style="color:#E1E395;">id</code> &#x2502; name&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; email&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502;
<br>
&#x251C;&#x2500;&#x2500;&#x2500;&#x2500;&#x253C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x253C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2524;
<br>
&#x2502; 1&#xA0;&#xA0;&#x2502; Alice&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; a****e@example.com&#xA0;&#xA0;&#x2502;
<br>
&#x2502; 2&#xA0;&#xA0;&#x2502; Bob&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; b****b@example.com&#xA0;&#xA0;&#x2502;
<br>
&#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2534;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2534;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;
<br>
2 rows (4ms)

</div>
<!--kg-card-end: html-->
<h2 id="dot-commands-for-database-introspection">Dot-commands for database introspection</h2>
<!--kg-card-begin: html-->
<p>
  If you&apos;ve used SQLite or libSQL&#x2019;s shell, these will feel familiar. Dot-commands give you quick access to your schema without writing
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">SELECT</code>
  queries against
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">sqlite_master</code>:
</p>

<ul>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.tables</code> list all tables</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.schema [table]</code> shows CREATE statements</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.describe table</code> column names, types, nullability, defaults, and primary keys</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.indexes [table]</code> list indexes with their target columns</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.fk table</code> show foreign key relationships</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.er</code> display a text-based entity-relationship overview of your entire schema, which is useful for getting to grips in an unfamiliar database without jumping through <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.describe</code> calls</li>
</ul>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

&#x2192;&#xA0;&#xA0;.er
<br>
<br>
<code style="color:#E1E395;">users</code>
<br>
&#xA0;&#xA0;<code style="color:#E1E395;">id</code>&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;INTEGER&#xA0;&#xA0;PK
<br>
&#xA0;&#xA0;email&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;TEXT
<br>
&#xA0;&#xA0;created_at&#xA0;TEXT
<br>
&#xA0;&#xA0;&#x2514;&#x2500; orders.user_id
<br>
<br>
orders
<br>
&#xA0;&#xA0;<code style="color:#E1E395;">id</code>&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;INTEGER&#xA0;&#xA0;PK
<br>
&#xA0;&#xA0;user_id&#xA0;&#xA0;&#xA0;&#xA0;INTEGER&#xA0;&#xA0;FK &#x2192; <code style="color:#E1E395;">users</code>.<code style="color:#E1E395;">id</code>
<br>
&#xA0;&#xA0;total&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;REAL
<br>
&#xA0;&#xA0;created_at&#xA0;TEXT

</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>For data operations:</p>

<ul>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.count table</code> row count</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.size table</code> storage size estimate</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.dump [table]</code> export as SQL INSERT statements</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.read file.sql</code> execute a SQL file</li>
</ul>

<p>
  Bunny Database charges against a read quota based on rows scanned. Commands that scan full tables
  (<code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.count</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.size</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.dump</code>)
  warn you before running and ask for confirmation. So an accidental query on a large table doesn&#x2019;t burn through your quota unexpectedly.
</p>
<!--kg-card-end: html-->
<h2 id="saved-views">Saved views</h2><p>Dot-commands are great for introspection, but some queries you run over and over. Saved views let you name and persist any query so you can re-run it without retyping.</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

&#x2192;&#xA0;&#xA0;SELECT u.name, count(o.<code style="color:#E1E395;">id</code>) as orders, sum(o.total) as revenue
<br>
&#xA0;&#xA0;&#xA0;FROM users u JOIN orders o ON o.user_id = u.<code style="color:#E1E395;">id</code>
<br>
&#xA0;&#xA0;&#xA0;GROUP BY u.<code style="color:#E1E395;">id</code> ORDER BY revenue DESC LIMIT 10;
<br>
&#x250C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x252C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2510;
<br>
&#x2502; name&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; orders &#x2502; revenue &#x2502;
<br>
&#x251C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x253C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x253C;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2524;
<br>
&#x2502; Alice&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; <code style="color:#C6979E;">42</code>&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; 8400.00 &#x2502;
<br>
&#x2502; Bob&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; <code style="color:#C6979E;">31</code>&#xA0;&#xA0;&#xA0;&#xA0;&#xA0;&#x2502; 6200.00 &#x2502;
<br>
&#x2514;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2534;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2534;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2500;&#x2518;
<br>
<code style="color:#C6979E;">2</code> rows (12ms)
<br>
<br>
&#x2192;&#xA0;&#xA0;.save <code style="color:#BDD95F;">top-customers</code>
<br>
&#x2713; Saved view <code style="color:#BDD95F;">&apos;top-customers&apos;</code>

</div>
<!--kg-card-end: html-->
<p>Next session, next machine, same query:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">
&#x2192;  .view top-customers
</div>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>The following commands cover the full lifecycle:</p>

<ul>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.save NAME</code> saves the last executed query as a named view</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.view NAME</code> executes a saved view</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.views</code> lists all saved views for this database</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.unsave NAME</code> deletes a saved view</li>
</ul>

<p style="font-style: italic;">
  Names follow the same rules as filenames: alphanumeric, hyphens, and underscores only. No dots, slashes, or spaces.
</p>

<h3>Personal and shared views</h3>

<p>
  Views are stored as plain
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.sql</code>
  files, scoped per database. They live in your global config directory
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">~/.config/bunny/views/&lt;database-id&gt;/</code>,
  personal to you and available across every project.
</p>

<p>
  You can override the storage location with the
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--views-dir</code>
  flag to point to any directory, for example, a folder inside your repo. Because views are just
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.sql</code>
  files, you can commit them and share them with your team: common reporting queries, debugging helpers, and onboarding examples. Everyone gets the same set.
</p>

<h2>Five output modes</h2>

<p>
  Switch formats on the fly with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.mode</code>:
</p>

<ul>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">default</code> clean, borderless columns</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">table</code> bordered ASCII table</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">json</code> array of objects, ready for piping to <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">jq</code></li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">csv</code> proper RFC-compliant CSV with escaped fields</li>
  <li><code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">markdown</code> GitHub-flavored pipe tables, useful for pasting into issues or docs</li>
</ul>

<p>
  You can also set the mode at launch with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--mode json</code>
  for scripting.
</p>

<h2>Automatic sensitive column masking</h2>

<p>
  Most of the time, you don&#x2019;t actually need to see the raw value of a
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">password_hash</code>
  or
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">api_key</code>
  column; you just need to know it&#x2019;s there. The shell masks sensitive column names automatically, keeping raw secrets out of your terminal, your scrollback buffer, and anything you pipe or paste.
</p>

<p>
  Detected patterns include
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">password</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">secret</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">api_key</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">auth_token</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">ssn</code>,
  and similar names. Emails get a partial mask
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">a****e@example.com</code>;
  secrets are fully redacted
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">********</code>.
</p>

<p>
  This works across all output modes, not just the interactive terminal. If you&apos;re exporting to CSV or JSON, masked columns stay masked.
</p>

<p>
  Toggle it off with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.unmask</code>
  when you actually need the raw values, or launch with
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">--unmask</code>.
</p>
<!--kg-card-end: html-->
<h2 id="non-interactive-mode">Non-interactive mode</h2><p>For scripting or quick lookups, skip the REPL entirely:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

# <code style="color:#948168;">Install globally</code>
<br>
<code style="color:#E1E395;">npm install</code> -g @bunny.net/database-shell
<br>
<br>
# <code style="color:#948168;">Inline query</code>
<br>
bsql libsql://my-database.bunnydb.net --token ... <code style="color:#BDD95F;">&quot;SELECT count(*) FROM orders&quot;</code>
<br>
<br>
# <code style="color:#948168;">Execute a file</code>
<br>
bsql libsql://my-database.bunnydb.net --token ... seed.sql
<br>
<br>
# <code style="color:#948168;">JSON output for scripting</code>
<br>
bsql libsql://my-database.bunnydb.net --token ... --mode json <code style="color:#BDD95F;">&quot;SELECT * FROM products&quot;</code>

</div>
<!--kg-card-end: html-->
<p>File execution splits on semicolons (properly handling quoted strings and comments) and stops on the first error.</p>
<!--kg-card-begin: html-->
<h2>Thank you <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">libsql-shell-go</code></h2>

<p>
  We want to acknowledge the
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">libsql-shell-go</code>
  project. It set the standard for what a libSQL shell should feel like: the dot-commands, the output modes, and the overall interaction model.
</p>

<p>
  We released an early look at a wrapper on top of
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">libsql-shell-go</code>,
  but it was clear that we could do more to improve the experience for Bunny Database users. Our implementation follows the same spirit while adapting it to the TypeScript ecosystem and the bunny.net developer experience.
</p>

<h2>Why rewrite in TypeScript?</h2>

<p>Three reasons:</p>

<ul>
  <li>
    <strong>Single dependency chain</strong> &#x2014; The upcoming bunny.net CLI is TypeScript. Embedding the shell directly means no sidecar binary, no version drift, and no separate release pipeline.
  </li>
  <li>
    <strong>Shared auth and config</strong> &#x2014; When you run
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db shell</code>,
    the CLI resolves your database credentials from your project&apos;s
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.env</code>,
    your authenticated profile, or explicit flags. The same way every other upcoming
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bunny db</code>
    command works. A separate Go binary can&apos;t participate in that flow without shelling out or duplicating logic.
  </li>
  <li>
    <strong>Standalone when you want it</strong> &#x2014; Despite being built for the bunny.net CLI,
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">bsql</code>
    is published as its own package
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">(@bunny.net/database-shell)</code>
    with zero CLI dependencies. You can use it as a library.
  </li>
</ul>
<!--kg-card-end: html-->
<h2 id="get-started">Get started</h2><p>Install the bunny.net CLI and connect to your first database:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#E1E395;">npm install</code> -g @bunny.net/database-shell
<br>
bsql

</div>
<!--kg-card-end: html-->
<p>Or use the database-shell directly via npx:</p>
<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

npx @bunny.net/database-shell libsql://your-database.bunnydb.net --token your-token

</div>
<!--kg-card-end: html-->
<p>Make sure to <a href="https://discord.com/invite/bunnynet" rel="noopener">join us on Discord</a> to share your experience and any feedback.</p><h2 id="whats-next">What&apos;s next</h2><p>A platform is only as good as its developer experience, and that experience increasingly lives in the terminal. Our goal with the bunny.net CLI is to give you a single tool for everything on the bunny.net stack. No tab-switching, no copy-pasting tokens between dashboards. The interactive Database shell is the first piece of that vision.</p><p>Over the coming weeks, we&#x2019;ll be rolling out commands for Database CRUD, Magic Containers, Edge Scripting, and Storage. The goal is a workflow where you can go from an empty project to a deployed, queryable application without ever leaving your terminal.</p>]]></content:encoded></item><item><title><![CDATA[Sovereign cloud and edge: bunny.net and UpCloud partner to power your global growth]]></title><description><![CDATA[For those new to our Finnish partner, they are a performance-obsessed European cloud services provider renowned for their commitment to transparency, cloud sovereignty, and delivering amazing developer experiences to over 10 thousand developers and enterprises alike.]]></description><link>https://bunny.net/blog/sovereign-cloud-and-edge-bunny-net-and-upcloud-partner-to-power-your-global-growth/</link><guid isPermaLink="false">69b93126160dc403fbfcef7b</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Graeme Inglis]]></dc:creator><pubDate>Tue, 17 Mar 2026 10:48:46 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/03/Bunny-Upcloud.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/03/Bunny-Upcloud.png" alt="Sovereign cloud and edge: bunny.net and UpCloud partner to power your global growth"><p>We have some &quot;ear-resistible&quot; news to share! bunny.net and <a href="https://upcloud.com/blog/upcloud-bunny-partner-power-sovereign-cloud-edge/" rel="noreferrer"><strong>UpCloud</strong> are officially teaming up</a> to offer a unified, high-performance, and digitally sovereign cloud-to-edge ecosystem.</p><p>In today&#x2019;s fast-paced world, you shouldn&apos;t have to choose between innovation and privacy. This strategic partnership enables organizations across both public and private sectors to access a European sovereign cloud and edge infrastructure through a seamless, integrated experience.</p><p>Together with <strong>UpCloud,</strong> we&apos;re providing you with a powerful solution to deploy, scale, protect, and deliver applications with total confidence.</p><h3 id="who-is-upcloud">Who is UpCloud?</h3><p>Sometimes partnerships just make sense. That is exactly what we saw in the team at UpCloud, a reflection of what we hold sacred at bunny.net.</p><p>For those new to our Finnish partner, they are a performance-obsessed European cloud services provider renowned for their commitment to transparency, cloud sovereignty, and delivering amazing developer experiences to over 10 thousand developers and enterprises alike.</p><h3 id="what-this-partnership-unlocks">What this partnership unlocks</h3><p>This collaboration is about more than just speed and security. It&apos;s about providing a European <strong>single-point-of-purchase</strong> environment that maintains data sovereignty at its core. Whether you&apos;re an independent developer or part of a large enterprise, this partnership focuses on how you can grow without boundaries.</p><ul><li><strong>Digital sovereignty as standard:</strong> Pair a high-performing European cloud platform with a global edge network, built and operated in the EU and available everywhere.</li><li><strong>Unified performance:</strong> Accelerate and secure your UpCloud-hosted websites and applications for a worldwide audience instantly, using UpCloud&#x2019;s enterprise-grade infrastructure with bunny.net&#x2019;s <strong>250</strong> Tbps+ network capacity.</li><li><strong>Seamless integration:</strong> Manage your stack efficiently with Bunny CDN and Shield integrated directly within the UpCloud Hub and API.</li></ul><figure class="kg-card kg-image-card"><img src="https://bunny.net/blog/content/images/2026/03/Dejan-Quote-blog.png" class="kg-image" alt="Sovereign cloud and edge: bunny.net and UpCloud partner to power your global growth" loading="lazy" width="2000" height="255" srcset="https://bunny.net/blog/content/images/size/w600/2026/03/Dejan-Quote-blog.png 600w, https://bunny.net/blog/content/images/size/w1000/2026/03/Dejan-Quote-blog.png 1000w, https://bunny.net/blog/content/images/size/w1600/2026/03/Dejan-Quote-blog.png 1600w, https://bunny.net/blog/content/images/size/w2400/2026/03/Dejan-Quote-blog.png 2400w" sizes="(min-width: 720px) 720px"></figure><h3 id="coming-to-a-burrow-near-you-in-summer-2026"><strong>Coming to a burrow near you in summer 2026</strong></h3><p>While our teams are already busy working to bring these services to your fingertips, please note that the integrated solution is currently in development and will be available starting in summer 2026. We&apos;re extremely excited to offer a massive hop forward for businesses looking to stay secure without compromising end-user data sovereignty.</p><h3 id="join-the-mission-for-a-better-internet"><strong>Join the mission for a better internet</strong></h3><p>bunny.net is a European company on a mission to make the internet hop faster! Headquartered in Slovenia, we power millions of websites worldwide. By joining forces with Finland-based <strong>UpCloud</strong>, we&#x2019;re doubling down on our commitment to building better, more secure internet experiences for everyone.</p><p>Don&#x2019;t let compliance hurdles or privacy concerns limit your global reach. If you&#x2019;d like to discover more about how our joint solution can help your business hop ahead of the competition, reach out to <a href="mailto:hello@upcloud.com">hello@upcloud.com</a> to learn more!</p>]]></content:encoded></item><item><title><![CDATA[Introducing the new Bunny Stream video player]]></title><description><![CDATA[From this point onward, new capabilities and features added to Bunny Stream will only be supported by the new player. At the same time, we don’t currently have plans to sunset the legacy player, so you won’t be forced to migrate. Your current embed URLs will continue to work.]]></description><link>https://bunny.net/blog/introducing-the-new-bunny-stream-video-player/</link><guid isPermaLink="false">69b2abf4160dc403fbfceeef</guid><category><![CDATA[News]]></category><dc:creator><![CDATA[Marek Nalikowski]]></dc:creator><pubDate>Thu, 12 Mar 2026 13:03:22 GMT</pubDate><media:content url="https://bunny.net/blog/content/images/2026/03/Bunny-Stream-2.0.png" medium="image"/><content:encoded><![CDATA[<img src="https://bunny.net/blog/content/images/2026/03/Bunny-Stream-2.0.png" alt="Introducing the new Bunny Stream video player"><p>When you&#x2019;re building video capabilities, the player component is the part of your video stack that&#x2019;s experienced directly by your users.</p><p>Today, we&#x2019;re glad to help improve their experience with the new Bunny Stream video player becoming generally available.</p><p>The new player offers:</p><ul><li><strong>Improved user interface</strong></li><li><strong>Faster, smoother playback</strong></li><li><strong>Consistency across all major browsers</strong></li><li><strong>Accessibility improvements</strong></li></ul><p>In this post, we&#x2019;ll cover:</p><ul><li>What this release means for new and existing video libraries</li><li>How to upgrade to the new player</li><li>What&#x2019;s under the hood and where the ecosystem is heading</li></ul><p>Let&#x2019;s get into it.</p><h2 id="how-the-new-player-rolls-out">How the new player rolls out</h2><ul><li><strong>New libraries use the new player by default</strong>. New video libraries created in Bunny Stream will automatically use the new player. If needed, you can switch a library back to the legacy player after it&#x2019;s created via a toggle in the dashboard or using the <a href="https://docs.bunny.net/api-reference/core/stream-video-library/update-video-library" rel="noreferrer">&apos;update video library&apos;</a> API endpoint.</li><li><strong>Existing libraries stay on the legacy player until you migrate them</strong>. Your existing libraries and embed URLs will continue to use the legacy player until you choose to migrate them to the new one.</li></ul><p>From this point onward, new capabilities and features added to Bunny Stream will only be supported by the new player. At the same time, we don&#x2019;t currently have plans to sunset the legacy player, so you won&#x2019;t be forced to migrate. Your current embed URLs will continue to work.</p><p>That said, migration is straightforward and your users will surely feel the difference, so we recommend you take one of the paths described below.</p><h2 id="how-to-upgrade-to-the-new-player">How to upgrade to the new player</h2>
<!--kg-card-begin: html-->
<h3>If you&#x2019;re not using custom CSS or JavaScript</h3>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<ul>
  <li>Log in to bunny.net</li>
  <li>Navigate to <strong>Stream</strong> from the sidebar</li>
  <li>Select the <strong>Video Library</strong> you&#x2019;d like to use the new player on</li>
  <li>Open <strong>Player Settings</strong></li>
  <li>Toggle off <strong>Enable Legacy Player</strong></li>
  <li>
    <strong>Update your embed URLs to use the new player endpoint:</strong> replace<br>
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">iframe.mediadelivery.net/embed/</code>
    with
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">player.mediadelivery.net/embed/</code>
  </li>
</ul>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  Alternatively, you can also upgrade your video library to the new player using the API by setting 
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">&quot;PlayerVersion&quot;: 2</code>, 
  like in the following example:
</p>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<div style="background-color:#1e1e1e; padding:1em; border-radius:6px; font-family:monospace; color:#ffffff;">

<code style="color:#BDDA60;">curl</code> --request POST \
<br>
&#xA0;&#xA0;--url https://api.bunny.net/videolibrary/{libraryId} \
<br>
&#xA0;&#xA0;--header <code style="color:#BDDA60;">&apos;AccessKey: YOUR_ACCESS_KEY&apos;</code> \
<br>
&#xA0;&#xA0;--header <code style="color:#BDDA60;">&apos;Content-Type: application/json&apos;</code> \
<br>
&#xA0;&#xA0;--data &apos;
<br>
{
<br>
&#xA0;&#xA0;<code style="color:#BDDA60;">&quot;PlayerVersion&quot;</code>: <code style="color:#C7989E;">2</code>
<br>
}

</div>
<!--kg-card-end: html-->
<p></p><p><strong>Note</strong>: Updating the video library via the dashboard or API does not automatically update your existing embed URLs. To use the new player endpoint, update your embed URLs to player.mediadelivery.net/embed/ instead of iframe.mediadelivery.net/embed/</p>
<!--kg-card-begin: html-->
<h3>If you&#x2019;re using custom CSS or JavaScript</h3>
<!--kg-card-end: html-->

<!--kg-card-begin: html-->
<p>
  If your player uses custom CSS or JavaScript via the 
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">Custom HTML head</code> 
  feature, you&#x2019;ll need to migrate the 
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">&lt;head&gt;</code> 
  markup to work with the new player.
</p>
<!--kg-card-end: html-->
<p>The new player uses a different structure and control elements, meaning CSS selectors or scripts targeting the legacy player may no longer work as expected.</p><p>We&#x2019;ve prepared a migration guide that explains how to update common customizations and map legacy selectors to the new player components.</p><p>What you&#x2019;ll need to do:</p>
<!--kg-card-begin: html-->
<ul>
  <li>
    <strong>Search your custom snippet</strong> for 
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">plyr</code>, 
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">.plyr__</code>, 
    and 
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">data-plyr</code>
  </li>
  <li><strong>Replace selectors</strong> using the mapping table in the migration guide</li>
  <li>
    <strong>Search for jQuery usage</strong> 
    (<code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">$(</code>, 
    <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">jQuery</code>) 
    and rewrite to vanilla JavaScript
  </li>
  <li><strong>Verify on desktop and mobile</strong>, especially controls and captions</li>
</ul>
<!--kg-card-end: html-->
<p>For detailed instructions and selector mappings, follow the complete migration guide <a href="https://docs.bunny.net/stream/custom-head-html-migration-guide" rel="noopener noreferrer">here</a>.</p><h2 id="what%E2%80%99s-under-the-hood-and-where-the-ecosystem-is-heading">What&#x2019;s under the hood and where the ecosystem is heading</h2><p>The new Bunny Stream player is built using <strong>Web Components</strong> and the open-source <a href="https://www.media-chrome.org/">Media Chrome</a> project.</p>
<!--kg-card-begin: html-->
<p>
  Media Chrome provides a set of composable HTML elements that represent common media controls. Elements such as
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">media-controller</code>,
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">media-play-button</code>,
  and
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">media-time-range</code>
  can be combined to build a player interface directly in the DOM.
  <br><br>
  Playback itself still relies on the native
  <code style="background-color:#1e1e1e; color:#EB5757; padding:2px 6px; border-radius:4px;">&lt;video&gt;</code>
  element. Media Chrome components are layered on top of it to provide the user interface.
</p>
<!--kg-card-end: html-->
<p>This HTML-first approach offers a few advantages:</p>
<!--kg-card-begin: html-->
<ul>
  <li><strong>More flexible customization</strong> using standard HTML and CSS</li>
  <li><strong>Improved accessibility</strong> through semantic control elements</li>
  <li><strong>More predictable control structure</strong> for styling and scripting</li>
</ul>
<!--kg-card-end: html-->
<p>This direction also reflects a broader shift happening across the web video ecosystem, with player architectures increasingly moving toward composable, HTML-first approaches.</p><p>By building the Bunny Stream player on Media Chrome, we&#x2019;re aligning with this evolution while keeping the player flexible and easier to extend over time.</p><h2 id="try-the-new-bunny-stream-player">Try the new Bunny Stream player</h2><p>The new Bunny Stream player is now available to everyone and will be used by default for all newly created video libraries.</p><p>If you&#x2019;re already using Bunny Stream, upgrading existing libraries is straightforward. Most libraries can switch to the new player with a single toggle or API call, while customized players can be migrated by following the guide linked above.</p><p>We recommend upgrading when convenient, so you can take advantage of the improvements in the new player and upcoming features.</p><h2 id="references">References</h2><ul><li><a href="https://docs.bunny.net/stream/player" rel="noopener noreferrer">Stream player docs</a></li><li><a href="https://docs.bunny.net/api-reference/core/stream-video-library/update-video-library#response-player-version" rel="noopener noreferrer">API reference</a></li><li><a href="https://docs.bunny.net/stream/custom-head-html-migration-guide" rel="noreferrer">Custom head HTML migration guide</a></li></ul>]]></content:encoded></item></channel></rss>