DEV Community

ahmet gedik
ahmet gedik

Posted on

Router-Level Ad-Free Video Discovery With NextDNS And DoH Filtering

Last month a reader emailed me a screenshot of our trending feed loaded on his TV browser. Half the viewport was covered by a third-party ad overlay that we never served. We run a GDPR-compliant analytics stack and no interstitial ad units at all, so the culprit was obvious: his ISP was injecting ads at the DNS resolution layer, and a browser extension was rewriting our clean pages on top of that. If you build a video discovery product for the European market, you eventually realize that the biggest threat to a clean viewing experience is not your own code — it is everything sitting between the user's TV and your origin. On ViralVidVault we spend a lot of time on Cloudflare Workers and cache headers, but the single most effective fix I have deployed for my own household and for the office network is moving DNS filtering to the router with NextDNS. This article is the engineering write-up I wish I had when I started.

The Concrete Problem With Device-Level Blocking

Most people who want ad-free browsing install uBlock Origin and stop there. That works on a laptop with Firefox. It fails everywhere else that actually matters for video discovery:

  • Smart TVs and set-top boxes run locked-down browsers with no extension support.
  • Casting from a phone streams the raw page, extensions do not travel with the cast.
  • Native mobile apps route requests outside the browser sandbox entirely.
  • Guest devices, consoles, and IoT gadgets have no blocker at all.

DNS filtering solves this by moving the decision upstream. Every device that resolves a hostname has to ask a resolver first. If you control the resolver, you control which hostnames ever resolve to an IP. Put that resolver on the router and every device on the LAN inherits the policy — no per-device install, no extension, no root.

NextDNS is a hosted resolver that speaks DNS-over-HTTPS (DoH) and DNS-over-TLS (DoT), lets you attach blocklists, and gives you a query log you actually own. For a European audience there is a second, less obvious benefit: it lets you pick EU points of presence and encrypt the query so your ISP cannot see or monetize the domains your users visit. That is a privacy posture I care about because it matches the promise we make on our own site.

How DNS Filtering Actually Works

When a device wants to load cdn.some-ad-network.example, it sends a query to whatever resolver the router hands out over DHCP. A normal ISP resolver answers with the real IP. A filtering resolver checks the hostname against a blocklist first. If it matches, it returns 0.0.0.0 or NXDOMAIN, and the connection is never attempted. The ad tracker was never contacted, so no bytes were downloaded and no tracking pixel fired.

The important nuance is transport. Plain DNS on UDP port 53 is unencrypted and trivially hijacked — this is exactly how ISPs inject ads and how captive portals redirect you. NextDNS closes that hole by wrapping the query in TLS:

  • DoH tunnels DNS inside HTTPS on port 443, indistinguishable from normal web traffic.
  • DoT uses a dedicated TLS port 853.

Most consumer routers cannot speak DoH natively, so the standard pattern is a small proxy on the router (or a Raspberry Pi) that accepts plain DNS on the LAN and forwards it encrypted to NextDNS. cloudflared and dnsproxy both do this. Here is the minimal cloudflared config that turns a Pi into a LAN resolver forwarding to a NextDNS profile:

# /etc/cloudflared/config.yml
proxy-dns: true
proxy-dns-address: 0.0.0.0
proxy-dns-port: 53
proxy-dns-upstream:
  # NextDNS profile ID goes in the path
  - https://dns.nextdns.io/abc123
proxy-dns-max-upstream-conns: 5
Enter fullscreen mode Exit fullscreen mode

Point the router's DHCP "DNS server" field at the Pi's LAN IP and every client now resolves through NextDNS over an encrypted channel. On OpenWrt you can skip the Pi entirely and run https-dns-proxy directly on the router.

Building A Blocklist That Does Not Break Video

Aggressive blocklists are the number one reason people give up on DNS filtering. Block the wrong CDN hostname and thumbnails vanish, the player buffers forever, or the whole page 500s because a first-party API call was collateral damage. For a video discovery site this is fatal — the product is the media.

The rule I follow: block tracking and ad-serving domains, never block media or first-party API domains. NextDNS ships curated lists (OISD, AdGuard, the Steven Black hosts file) that are already tuned for this, but you still want to verify against your own traffic. I wrote a small analyzer that reads a NextDNS log export and flags any blocked domain that looks media-related so I can whitelist it before it hurts a real user.

import csv
import sys
from urllib.parse import urlparse

# Substrings that indicate a domain is load-bearing for video playback.
MEDIA_MARKERS = ("cdn", "video", "media", "stream", "img", "thumb", "static", "assets")
TRACKER_MARKERS = ("track", "analytics", "pixel", "ads", "doubleclick", "adservice")


def classify(domain: str) -> str:
    d = domain.lower()
    if any(m in d for m in TRACKER_MARKERS):
        return "tracker"
    if any(m in d for m in MEDIA_MARKERS):
        return "media-risk"
    return "unknown"


def audit(log_path: str) -> None:
    risky = []
    with open(log_path, newline="") as fh:
        for row in csv.DictReader(fh):
            if row.get("status") != "blocked":
                continue
            label = classify(row["domain"])
            if label == "media-risk":
                risky.append(row["domain"])

    for domain in sorted(set(risky)):
        print(f"REVIEW whitelist candidate: {domain}")
    print(f"\n{len(set(risky))} media-risk domains blocked — review before shipping.", file=sys.stderr)


if __name__ == "__main__":
    audit(sys.argv[1])
Enter fullscreen mode Exit fullscreen mode

I run this after every blocklist change. Anything flagged media-risk gets manually checked against a real playback session before I trust the list on the household router. This is the same defensive instinct that keeps our SQLite WAL migrations from ever dropping a column without a backfill first — verify against real data, never assume.

Why This Matters On The Origin Side Too

Here is the part most tutorials miss. DNS filtering changes what your server sees, and if you are not careful it will corrupt your own analytics. When a client blocks a tracker domain, some third-party scripts retry against a fallback that hits your origin, and some blocked-but-cached responses arrive with mangled headers. On a European site you are already supposed to be measuring only what you strictly need under GDPR, so this is a good excuse to make your logging robust.

On ViralVidVault we log a minimal, cookieless request record. The trick is to treat a filtered/incomplete request as a first-class case instead of letting it throw. Here is the PHP 8.4 request classifier we use before anything touches SQLite. It leans on the new property hooks and readonly semantics to stay compact:

<?php
declare(strict_types=1);

final class RequestSignal
{
    // Domains a DNS filter commonly blocks; if we see referrers from them
    // we know the client is *not* running a filter, useful for cohorting.
    private const array TRACKER_HINTS = ['doubleclick.net', 'googlesyndication.com'];

    public function __construct(
        public readonly string $ua,
        public readonly ?string $referer,
        public readonly bool $doh,
    ) {}

    public bool $likelyFiltered {
        // A DoH-resolved client with no tracker referrer is probably
        // behind a filtering resolver like NextDNS.
        get => $this->doh && !$this->hasTrackerReferer();
    }

    private function hasTrackerReferer(): bool
    {
        if ($this->referer === null) {
            return false;
        }
        foreach (self::TRACKER_HINTS as $host) {
            if (str_contains($this->referer, $host)) {
                return true;
            }
        }
        return false;
    }

    public static function fromServer(array $server): self
    {
        return new self(
            ua: $server['HTTP_USER_AGENT'] ?? '',
            referer: $server['HTTP_REFERER'] ?? null,
            // Cloudflare exposes the resolver transport in this header.
            doh: ($server['HTTP_CF_RAY'] ?? '') !== ''
                && str_starts_with($server['HTTP_X_FORWARDED_PROTO'] ?? '', 'https'),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The point of this class is not to track anyone — it stores nothing personal and no cookie — it is to bucket traffic so our trend numbers stay honest when a growing share of visitors resolve through filters. If you count filtered and unfiltered sessions the same way, your engagement metrics drift and your "viral" ranking rewards the wrong videos.

Serving Clean Pages At The Edge

DNS filtering handles the client side. The edge is where you make sure a filtered client still gets a fast, complete page. Because filtered clients skip a lot of third-party JS, they hit your origin more directly, which means your cache hit ratio matters even more. On our stack a Cloudflare Worker does the last-mile cleanup: it strips any third-party ad/beacon injection an upstream proxy might have added and normalizes cache headers so LiteSpeed can serve from its page cache.

// Cloudflare Worker — normalize responses for filtered clients
export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request);
    const ct = response.headers.get("content-type") || "";

    // Only rewrite HTML; media and JSON pass straight through untouched.
    if (!ct.includes("text/html")) {
      return response;
    }

    const headers = new Headers(response.headers);
    // Never let an upstream inject a permissive ad-friendly CSP.
    headers.set(
      "content-security-policy",
      "default-src 'self'; media-src 'self' https://*.viralvidvault.com; img-src 'self' data:;"
    );
    headers.set("cache-control", "public, max-age=10800, stale-while-revalidate=7200");
    headers.delete("set-cookie"); // cookieless by design for GDPR

    return new Response(response.body, {
      status: response.status,
      headers,
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

The CSP is the real workhorse here. Even if a user's network does try to inject an ad script, a strict default-src 'self' means the browser refuses to execute it. DNS filtering blocks the request; CSP blocks the execution. Defense in depth, and both cost you nothing per request at the edge.

Measuring The Impact Without Violating Privacy

You cannot manage what you do not measure, but under GDPR you also cannot measure whatever you like. The compromise I settled on is aggregate-only counters: I never store an IP or a client identifier, I just increment counters for filtered vs unfiltered page loads and compare median time-to-first-frame between the two cohorts. Here is a small Go service that ingests those anonymized signals and keeps rolling aggregates in memory, flushing to SQLite periodically. Go because I wanted the concurrency to be trivially correct under load.

package main

import (
    "sync"
    "sync/atomic"
)

// Cohort holds aggregate-only counters. No per-user data ever lands here.
type Cohort struct {
    PageViews  atomic.Uint64
    TotalTTFFms atomic.Uint64 // sum of time-to-first-frame in ms
}

func (c *Cohort) Record(ttffMs uint64) {
    c.PageViews.Add(1)
    c.TotalTTFFms.Add(ttffMs)
}

// MedianProxy uses mean as a cheap, privacy-safe stand-in for the trend.
func (c *Cohort) MeanTTFF() float64 {
    views := c.PageViews.Load()
    if views == 0 {
        return 0
    }
    return float64(c.TotalTTFFms.Load()) / float64(views)
}

type Stats struct {
    mu       sync.RWMutex
    Filtered Cohort
    Plain    Cohort
}

func (s *Stats) Ingest(filtered bool, ttffMs uint64) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    if filtered {
        s.Filtered.Record(ttffMs)
    } else {
        s.Plain.Record(ttffMs)
    }
}

func main() {
    stats := &Stats{}
    // wire stats.Ingest into your ingest endpoint; flush aggregates on a ticker.
    _ = stats
}
Enter fullscreen mode Exit fullscreen mode

When I ran this comparison across a week of real traffic, the filtered cohort loaded our trending page noticeably faster — no surprise, since they downloaded fewer third-party bytes. That is the whole thesis in one number: blocking ad and tracker DNS is not only cleaner, it is faster, and speed is what makes a discovery feed feel alive.

Deploying It On A Real Router

The rollout order matters if you do not want to lock a household out of the internet mid-change:

  • Create the NextDNS profile first. Pick EU points of presence, attach OISD + AdGuard base lists, and enable the native tracking-protection toggles.
  • Test on one device using the NextDNS setup guide before you touch the router. Confirm your own site loads perfectly, including player and thumbnails.
  • Add the DoH proxy (cloudflared, https-dns-proxy, or router-native support on GL.iNet / OpenWrt / a Firewalla).
  • Change DHCP DNS last, and keep a second known-good resolver as a fallback so a proxy crash does not take the LAN offline.
  • Run the log audit from the Python script above after a day of real use, and whitelist any media-risk false positives.

A few operational gotchas I hit and you will too:

  • Some smart TVs hardcode Google's 8.8.8.8 and ignore DHCP. You need a firewall rule that NATs or blocks outbound port 53 to force them onto your resolver.
  • Chromecast and some Android TV devices try DoH directly to Google, bypassing your router entirely. Block outbound dns.google and 1.1.1.1:443 at the firewall if you want full coverage.
  • NextDNS logging is opt-in and configurable — for a privacy-first household, turn logs off or set a short retention. The filtering still works with zero logging.

Conclusion

Moving ad and tracker filtering to the DNS layer on the router is the highest-leverage change I have made for clean video viewing, and it maps directly onto the engineering values behind our own product: block at the source, encrypt in transit, keep first-party media untouched, and measure only in aggregate. NextDNS gives you an encrypted EU resolver, curated blocklists, and a log you control; a small DoH proxy brings it to every device on the LAN; a strict CSP at the edge finishes the job for the rare injection that slips through. For anyone building or running a European video discovery site the lesson generalizes past your own code — the fastest, cleanest experience comes from controlling the layers most people ignore, and DNS is the one that touches every device your users own.

Top comments (0)