DNS is Beautiful

DNS setup embed

<dns-setup> v0.1.4 · Live demo · manifest.json

Overview

Put <dns-setup> on the page where your customers point their domain at your product. You declare the DNS records the domain needs; the widget does the rest:

  • detects the customer’s DNS host from the domain’s nameservers (the customer can pick another one),
  • shows each step in that host’s terms: field labels, whether to type the full name or only the part before the domain, and the host’s own notes,
  • checks each record against public resolvers while the page is open and marks it verified when it’s right,
  • reports progress to your page as DOM events.

It is a custom element that renders in a shadow root inside your page, not an iframe. It inherits your font and text color, and you style it with CSS custom properties and ::part(). There is no account, API key or token. A “Send instructions” button gives the customer plain-text steps to forward to whoever manages their DNS.

See it running on the live demo, which also logs every event.

Installation

Load the script once per page, then place the element where the instructions should appear:

<script src="https://dnsisbeautiful.com/embed/dns-setup-v0.1.4.96d9b173c1.js"
  integrity="sha384-phk+krKQlfbKFSWL7YO1vZCBCoCWZ9Ei8sLGjRUm5bOf93TWtckwpYnvQC3b7yof"
  crossorigin="anonymous" defer></script>

<dns-setup domain="example.com"
  records='[{"type":"A","name":"@","value":"192.0.2.10"},{"type":"A","name":"www","value":"192.0.2.10"}]'>
  <!-- Fallback: your existing instructions, shown if the script doesn't load. -->
  <p>Add an A record for example.com and www.example.com pointing at 192.0.2.10.</p>
</dns-setup>
  • domain (required): the zone the records belong to, such as example.com.
  • records (required): the declaration, a JSON array of 1 to 20 entries. You can also set it as a property, below.
  • provider (optional): a DNS host id from the list below, to preselect instead of detecting. An unknown id is ignored. The customer can still change it.
  • api-base (optional): the origin that serves /api/dns-check. Defaults to the origin the script was loaded from, so leave it out unless you proxy the API.
  • resolvers (optional): comma-separated public resolvers (IPs or names from DNS is Beautiful’s checker, at most 8) used to decide “verified”. Defaults to 8.8.8.8,8.8.4.4 (Google). An unknown resolver makes every check fail with an error.

Changing domain, records, api-base or resolvers resets the widget and starts checking again from the first check. Changing provider switches the instructions in place.

Versions and integrity

Each release is published under a new content-hashed file name (dns-setup-v<version>.<hash>.js) that never changes, so the integrity hash stays valid and the file is cached for a year. Older files stay published. To upgrade, copy file (or path) and integrity from /embed/manifest.json, which always describes the current release, and read the changelog first. crossorigin="anonymous" is required for the integrity check on a cross-origin script.

If your site sends a Content Security Policy, allow https://dnsisbeautiful.com in script-src and connect-src, and in img-src for the DNS hosts’ icons (served from /embed/favicons/).

Fallback content

Anything inside <dns-setup> is fallback content. It shows while the element is undefined (script blocked, offline, failed integrity check), is hidden once the widget renders, and shows again if domain or records is invalid. In that case the widget logs the reason as a console warning starting <dns-setup>:. Put your existing static instructions there, and add dns-setup { display: block; } so they lay out normally.

To avoid a flash of fallback content while the script loads, hide it until the element is defined, show a loading line, and reveal the fallback after a timeout:

dns-setup { display: block; }

/* While the script loads: hide the fallback and show a loading line. */
dns-setup:not(:defined):not([data-fallback]) > * { display: none; }
dns-setup:not(:defined):not([data-fallback])::before {
  content: "Loading DNS instructions…";
  color: #6b7280;
}
// Still not defined after 8 seconds (script blocked, offline, integrity mismatch):
// show the fallback instead of the loading line.
setTimeout(() => {
  if (customElements.get('dns-setup')) return
  for (const el of document.querySelectorAll('dns-setup')) el.setAttribute('data-fallback', '')
}, 8000)

The widget ignores data-fallback; if the script loads after the timeout, the widget replaces the fallback as usual.

Setting records from a template or from JavaScript

records is JSON inside an HTML attribute, so attribute-escape it (&, ", ', <) like any other attribute value. Most template engines do this for interpolated values. Don’t print raw JSON into a single-quoted attribute: an apostrophe in a TXT value ends the attribute.

<%# ERB: <%= %> escapes " as &quot;, so the JSON survives a double-quoted attribute %>
<dns-setup domain="<%= @domain %>" records="<%= @records.to_json %>"></dns-setup>

Or set the records property to an array. It can be set before the script has loaded:

const el = document.querySelector('dns-setup')
el.records = [{ type: 'TXT', name: '@', value: "v=spf1 include:_spf.example.net ~all" }]
DNS host ids for provider (57)

101domain (101domain), akamai (Akamai Edge DNS), route53 (Amazon Route 53), azure (Azure DNS), bluehost (Bluehost), bunny (Bunny DNS), cloudflare (Cloudflare), cloudns (ClouDNS), constellix (Constellix), csc (CSC), digitalocean (DigitalOcean), dnsmadeeasy (DNS Made Easy), dnsimple (DNSimple), dreamhost (DreamHost), easydns (easyDNS), enom (Enom (Tucows)), eurodns (EuroDNS), gandi (Gandi), godaddy (GoDaddy), google_cloud_dns (Google Cloud DNS / Squarespace Domains), hetzner (Hetzner), hostgator (HostGator), hostinger (Hostinger), hover (Hover), hurricane_electric (Hurricane Electric), infomaniak (Infomaniak), ionos (IONOS), linode (Linode (Akamai Cloud)), loopia (Loopia), markmonitor (MarkMonitor), microsoft365 (Microsoft 365), muumuu (Muumuu Domain), name_com (Name.com), namecheap (Namecheap), namesilo (NameSilo), netlify (Netlify), network_solutions (Network Solutions), ns1 (NS1 (also behind Netlify, Squarespace and Heroku DNS)), onamae (Onamae.com), onamae_hosting (Onamae.com hosting (rental server / mail plans)), ovh (OVHcloud), pointdns (PointDNS), porkbun (Porkbun), rackspace (Rackspace), register_com (Register.com), register_it (Register.it), safenames (Safenames), siteground (SiteGround), spaceship (Spaceship), squarespace (Squarespace), ultradns (UltraDNS), value_domain (Value Domain), vercel (Vercel), vultr (Vultr), wix (Wix), wordpress_com (WordPress.com), xserver (Xserver). Without a known id the widget shows general instructions.

The declaration

records declares what the domain’s DNS should look like. It is not a list of steps: you never say “add”, “change” or “delete”. The widget looks up each entry’s name and works out the steps to show (DNS rules).

  • { "type", "name", "value" }: this record should exist.
  • { "type", "name", "value", "remove": true }: this record should not exist. A value of "*" means no record of that type may remain at the name.

Fields

  • type: one of CNAME, A, AAAA, TXT, MX (case-insensitive).
  • name: @ for the domain itself, a full name ending in the domain (www.example.com), or a name relative to it (www, _dmarc, s1._domainkey). A name that doesn’t end in the domain is always relative: other.com means other.com.example.com. An empty name is rejected; use @.
  • value: an IPv4 address (A), an IPv6 address (AAAA), a hostname (CNAME), text (TXT, up to 2048 characters; a quoted value or several quoted strings are joined), or a mail host with an optional priority (MX, 10 mx.example.com). "*" means any value. Control characters and invisible or direction-changing Unicode characters are rejected.
  • remove: true, or leave it out.
  • fallback (optional, CNAME at @ only): 1 to 8 address records, [{ "type": "A", "value": "192.0.2.10" }, { "type": "AAAA", "value": "2001:db8::10" }], for DNS hosts that can’t point the root at a hostname. Each item has only type (A or AAAA) and value (an IP address, not *). On any other entry, or with remove, it is rejected: "fallback" is only allowed on a CNAME entry at the root domain (@).

One entry per record you care about. To cover both example.com and www.example.com, declare both.

Wildcard names

A name may start with one * label to name the zone’s wildcard record: * (becomes *.example.com), *.example.com, or *.dev (becomes *.dev.example.com). It is checked literally, as the record named *.example.com. a.*.example.com, ** and *x.example.com are rejected.

Contains-match (~)

A TXT value starting with ~ matches any TXT record that contains the text after it, for tokens embedded in longer records. It works on TXT only (other types reject it as an invalid value) and needs text after the ~. An add whose ~ text is an SPF or DMARC policy is rejected, because a name holds one policy and the widget needs the whole record to change it. A removal may use ~ with any text.

Retired fields

Before v0.1.2 entries carried action and match. An entry with either is now rejected, and so is a remove that isn’t true. The error names the entry (Entry 2: …):

  • action: "action" is no longer used; omit it to add, or set "remove": true.
  • match: "match" is no longer used; SPF and DMARC TXT values are matched to the existing policy automatically.
  • remove: "remove" must be true, or omitted to add.

Example: hosting provider

Point the domain and www at your server and make sure no IPv6 address bypasses it:

[
  {
    "type": "A",
    "name": "@",
    "value": "192.0.2.10"
  },
  {
    "type": "A",
    "name": "www",
    "value": "192.0.2.10"
  },
  {
    "type": "AAAA",
    "name": "@",
    "value": "*",
    "remove": true
  },
  {
    "type": "AAAA",
    "name": "www",
    "value": "*",
    "remove": true
  }
]

If www is still a CNAME to the old host, the widget shows “Delete the CNAME record” above “Add an A record”. If the domain already has another A record, its row reads “Change the A record” with the current address. You don’t declare the CNAME removal yourself.

Example: root domain on a CDN

Point the root and www at a CDN hostname. The root can’t hold a real CNAME, so the root entry is a root alias, with fixed addresses for DNS hosts that have no alias:

[
  {
    "type": "CNAME",
    "name": "@",
    "value": "edge.example.net",
    "fallback": [
      {
        "type": "A",
        "value": "192.0.2.10"
      },
      {
        "type": "AAAA",
        "value": "2001:db8::10"
      }
    ]
  },
  {
    "type": "CNAME",
    "name": "www",
    "value": "edge.example.net"
  }
]

Example: email provider

SPF, a DKIM key delegated by CNAME, and DMARC:

[
  {
    "type": "TXT",
    "name": "@",
    "value": "v=spf1 include:_spf.example.net ~all"
  },
  {
    "type": "CNAME",
    "name": "s1._domainkey",
    "value": "s1.dkim.example.net"
  },
  {
    "type": "TXT",
    "name": "_dmarc",
    "value": "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
  }
]

If the domain already has an SPF record, the first row reads “Change the TXT record” and shows the current policy; other TXT records at the domain (site verification tokens) are left alone. The same goes for an existing DMARC policy at _dmarc.

DNS rules: how steps are derived

Each entry becomes one row, plus a generated “Delete the CNAME record” row for an A or AAAA entry whose name is a CNAME (and, for a root alias, delete rows for other root addresses or one row per fallback address). A row has a heading (the step) and a status. Steps for an entry come only from that entry and its own lookups; entries never affect each other. The one exception is a dangling CNAME at a declared name, which gets its own delete row.

EntryWhat the widget showsVerified when
A“Add an A record” / Not found, when the name has no A record. “Change the A record” / Currently 198.51.100.7, when it holds other addresses (up to three are listed, then “+N more”). A CNAME at the name adds a “Delete the CNAME record” row first (see below).Every public resolver returns only the value, and no CNAME holds the name.
AAAASame as A: “Add an AAAA record”, “Change the AAAA record”, and a generated “Delete the CNAME record” row when a CNAME holds the name.Every public resolver returns only the value (IPv6 compared in expanded form), and no CNAME holds the name.
CNAME“Add a CNAME record” / Not found, or “Change the CNAME record” / Currently <target>. Other records that would block a CNAME at the name are not looked up.The name’s CNAME is the value (case and trailing dot ignored).
CNAME at the root (@)A root alias, in the DNS host’s terms: “Add an ALIAS record” (or ANAME, or the host’s own type), or “Add a CNAME record” on a host that flattens it. A host with no alias gets the fallback addresses instead, or a note explaining why it can’t be done there. Other A or AAAA records standing where the alias should be get generated “Delete the A record” rows, only beside the add they make room for: none when the host has no alias and there is no fallback (see Root domain aliases).The root’s addresses are the target’s, as the same resolver sees them, or exactly the fallback addresses.
TXT: SPF or DMARCSPF is a value starting v=spf1 followed by a space or the end (v=spf10 is not SPF). DMARC is a value starting v=DMARC1 followed by ;, a space or the end, at a name whose first label is _dmarc. Both are case-insensitive. A name holds one policy, so an existing one reads “Change the TXT record” / Currently <that policy>; with none, “Add a TXT record”. Other TXT records at the name are ignored.Exactly one TXT record with that tag exists, and it equals the value.
TXT: anything else“Add a TXT record” until present. It sits beside existing TXT records and never reads “Change”.A TXT record equals the value (or, for a ~ value, contains the text after the ~).
MX“Add an MX record” until present. It sits beside other mail hosts and never reads “Change”.An MX record has the host, and the priority too when the value gives one (10 mx.example.com).
Any type, remove: true“Delete the AAAA record” / Still present, then Removed. With "*" the row lists the values actually found, so the customer deletes the right ones. A declared removal always shows and stands alone, with no add beside it: removing a record that points at a host someone else could claim is a fix in itself. Generated delete rows are different: they exist only to make room for an add, so they only appear with that add.No public resolver returns the value (with "*", any record of the type).
CNAME at @, remove: trueA root alias removal, named like the add: “Delete the ALIAS record” (or the DNS host’s own type), “Delete the CNAME record” on a host that flattens it or has no alias, “Delete the ALIAS record” when the host is unknown. Still present, then Removed. With "*" it is a plain CNAME removal (any CNAME at the root), because there is no target to compare addresses with.None of the root’s A or AAAA addresses are among the target’s on the same resolver (addresses an A or AAAA entry at @ in the same declaration adds don’t count), and no public resolver or queried nameserver holds a real CNAME at the root to the target. A failed root, target or CNAME lookup is never Removed.
Dangling CNAME (not declared)At any declared name: a generated “Delete the CNAME record” row that stands alone, with an amber note that the CNAME points at a name that no longer exists. Still present, then Removed. See Dangling CNAMEs.The CNAME is gone (or no longer points at a missing name). Until then verified stays false.

Root domain aliases

A CNAME entry at @ (the domain itself) is a root alias: a real CNAME can’t sit at the root beside its SOA and NS records, so DNS hosts offer an ALIAS or ANAME record, or flatten a CNAME there, answering with the target’s addresses. Its value must be a hostname, not "*". A removal at @ with a target is a root alias removal: a flattened ALIAS never shows up as a CNAME in public DNS, so it is judged by the root’s addresses, and it is Still present while any root A or AAAA address is among the target’s, or the root still holds a real CNAME to the target. A root address that an A or AAAA entry at @ in the same declaration adds doesn’t count, so swapping an ALIAS for the host’s A record clears even when that address is also one of the target’s. A "*" removal at @ stays a plain CNAME removal. The row depends on the detected or chosen DNS host:

  • A host with its own record type (DNSimple, DNS Made Easy, NS1 and others): “Add an ALIAS record” or “Add an ANAME record”, named as in that host’s type list, with a note naming the host’s feature.
  • A host that flattens a root CNAME (Cloudflare, Hostinger and others): “Add a CNAME record” with a note that the host flattens it automatically.
  • A host with no alias (including Route 53 and Azure, whose aliases only target their own cloud resources): with fallback, one “Add an A record” or “Add an AAAA record” row per address, the first with a note that the fixed addresses replace the alias. Without it, the row carries an amber note: “<host> can’t point the root at another hostname. Ask the requesting service for an A record address, or use the www name and redirect the root.”
  • Any other host: “Add an ALIAS record” with a general note about ALIAS, ANAME and CNAME flattening.

When a resolver shows the alias clearly not in place, the root’s A (or AAAA) records that aren’t fallback addresses get a generated “Delete the A record” (or AAAA) row listing them, because an alias can’t sit beside them. “Clearly not in place” means, per address type, that none of the root’s addresses are among the target’s on that resolver and they aren’t exactly the fallback addresses. If some are the target’s, the alias is there and a rotating or geo-routed target has moved on, so there is no delete row. On a host with no alias and no fallback there is nothing to add, so there are no delete rows either, only the note. The row stays on screen as Removed once a check finds the addresses gone, listing every address it showed, like the CNAME delete row. Events still report the one entry, with type: "CNAME".

Each resolver looks up the root’s A and AAAA records and the target’s, and the entry is verified on that resolver when the root has at least one A record and all of them are among the target’s A records, and the target has AAAA records and the root’s are among them, or the target has none and neither does the root. Comparing on the same resolver keeps geo-routed CDN targets working. A failed lookup of the root or the target is never verified. A real CNAME at the root to the target counts too (a resolver follows it to the target’s addresses; a nameserver returns the CNAME itself). With fallback, the entry is also verified when the root’s A and AAAA records are exactly the fallback addresses, whatever the DNS host.

A CNAME at an A or AAAA name

A CNAME can’t share its name with other records, and lookups follow it, so an A answer at a CNAME’d name is really the target’s address. For an A or AAAA entry the widget is strict: a generated “Delete the CNAME record” row comes first (the current target as reference text, status Still present), followed by “Add an A record” / Not found. The entry is never verified while the CNAME is there, even if the CNAME resolves to the right address, nor while the CNAME lookup fails. Each resolver’s answer is judged by that resolver’s own CNAME answer, and each queried nameserver’s by its own, so once the customer swaps the CNAME for the A record the row can read Saved at host before public caches expire. Once a check finds the CNAME gone, the delete row stays on screen as Removed for the rest of the page view.

TXT and MX through a CNAME

TXT and MX entries are judged on the answers as resolved, following a CNAME, because DKIM selectors and _acme-challenge names are delegated by CNAME on purpose. If the value is reached through the CNAME, the entry is verified and no delete row appears. If no resolver reaches it, the row reads “Blocked by CNAME” with an amber first note: “This name is a CNAME to <target>; a TXT record can’t sit beside it.” If some resolvers reach the value and others don’t yet (a new delegation propagating), the row reads Needs changing with no note. The widget never asks the customer to delete a CNAME for a TXT or MX entry.

Removals

A removal is always a “Delete the … record” row and is judged on the answers as resolved. At a name that is a CNAME, that means the target’s records. A CNAME removal at @ with a target is judged by addresses instead (Root domain aliases).

Dangling CNAMEs

A CNAME whose target no longer exists is a security problem: if the target was a deleted app or bucket on a hosting service, anyone who claims that name there can serve their own content on your customer’s domain (a subdomain takeover). So the widget checks every name in your declaration (and only those: it never scans the zone) for one. When the name holds a CNAME and its target is NXDOMAIN, it shows a generated “Delete the CNAME record” row with the target as reference text and an amber note: “This CNAME points at <target>, a name that no longer exists. Anyone who claims that name could serve their own content here, so delete it. If you don’t see this record, check for a wildcard (*) CNAME.” When the missing name is inside your domain, nobody else can claim it, so the note says only that it is a name in your own domain that doesn’t exist. The last sentence is there because a wildcard CNAME (*.example.com) answers for names with no record of their own. Unlike other generated rows it stands alone, with no add beside it, because deleting it is a fix in itself, like a declared removal. It comes before the name’s first entry, and once a check finds the CNAME gone it stays on screen as Removed.

  • NXDOMAIN only. The CNAME is read on the first selected resolver, then the target’s A record is asked of every selected public resolver (never the domain’s own nameservers). It is dangling only when every selected resolver answers NXDOMAIN. If any of them fails (SERVFAIL, a timeout, a refusal), that check gives no verdict, so a failing lookup never raises the row. A target that is itself a CNAME is followed: the note names the last name in the chain.
  • Two checks in a row. The server’s dangling is a verdict on one check. The widget shows the row only once two consecutive checks report the same CNAME target at the name, so a one-off answer never asks for a delete.
  • Not on delegation names. A name whose entries are all TXT or MX (an _acme-challenge or DKIM selector delegated by CNAME) is not checked: those targets can legitimately be NXDOMAIN between challenges, and deleting the CNAME would break certificate renewals. Add any other entry at the name to have it checked.
  • Covered by your declaration: a CNAME entry at the name (its target is yours; a missing one is that entry’s own mismatch), a "*" CNAME removal there, or a removal of that same target get no extra row. A root alias removal is judged by addresses, so it doesn’t cover a dead CNAME at the root, even to the same target. If an A or AAAA entry at the name already shows “Delete the CNAME record”, that row carries the note instead of a second row.
  • verified waits on it. While any declared name holds a dangling CNAME, the API’s and the MCP tool’s verified is false (they list it in dangling: { name, target, end }), so the widget keeps checking and dns-setup:verified doesn’t fire until it’s deleted. dns-setup:status still reports your entries only: every entry can read verified while the state stays pending.
  • What DNS can’t see. This is a DNS-only check. A CNAME to a hosting service that answers for every name under its domain (a wildcard record such as *.herokuapp.com, or a storage bucket endpoint) still resolves after the app or bucket behind it is deleted, so it isn’t flagged. No row doesn’t mean no takeover risk.

Statuses

  • Verified (shown with ✓): every selected public resolver agrees with the entry. On a removal or generated delete row: Removed.
  • Saved at host: the domain’s own nameservers have the change but public resolvers don’t yet (cached answers).
  • Not found, Currently …, Blocked by CNAME, Still present: needs a change, with what the name holds now.
  • Needs changing: needs a change, but the widget doesn’t claim what’s there (a lookup failed, or resolvers disagree on a value reached through a CNAME). It reads “Add …”, or keeps “Change …” if the row already read that in this page view.
  • Checking before the first answer; Unable to check when lookups failed.

Headings keep their verb

“Change” is shown only when a lookup saw the old value. A row that showed “Change the A record” keeps that heading once the customer fixes it (“Change the A record ✓ Verified”), and through a failed lookup, for the rest of the page view. A page that loads with the record already correct reads “Add an A record ✓ Verified”.

Events

The element dispatches CustomEvents that bubble and cross the shadow boundary (composed), so you can listen on the element or on document. Events describe your declaration entries only, in order: one record per entry, never a generated row. The rows on screen and the “Send instructions” text include generated rows; events don’t.

dns-setup:status

{
  state: 'checking' | 'pending' | 'verified' | 'error' | 'paused',
  message: string,            // the status line shown in the widget
  records: [{
    type: 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX',
    name: string,             // relative to domain: "@", "www", "*"
    value: string,            // as declared, e.g. "*", "10 mx.example.com"
    remove?: true,            // only on removals
    status: 'checking' | 'verified' | 'saved' | 'unknown' | 'needs-change',
  }],
}

Fires on load and whenever the state, the message or any entry’s status changes. status is the raw value; the labels above are display text. pending means waiting for the next automatic check. error means the last check failed; records keep their last good statuses. paused means the tab is hidden or automatic checks have stopped.

dns-setup:verified

{ domain: 'example.com', records: [/* as above, every status 'verified' */], checkedAt: '2026-10-24T14:03:11.512Z' }

Fires once, when every entry is verified on every selected resolver and no declared name holds a dangling CNAME. Checking then stops. Changing an attribute starts over and can fire it again.

dns-setup:provider-change

{ provider: 'cloudflare', detected: 'cloudflare', source: 'host' | 'detected' | 'user' }

Fires once a DNS host is decided (from the provider attribute, the customer’s choice, or the first completed check), then on each change. provider is the host whose steps are shown, or unknown for general instructions. detected is the host identified from nameservers, or null when the entries’ nameservers don’t all point to one known host.

Treat “verified” as a hint

The events come from code running in the customer’s browser, and any script on your page (or the customer, from the console) can dispatch a dns-setup:verified event. Use it to update the UI or to trigger your own server-side check, never as proof that DNS is ready.

const el = document.querySelector('dns-setup')

el.addEventListener('dns-setup:status', (event) => {
  const { state, message, records } = event.detail
  progress.textContent = message
  console.log(state, records.map((r) => `${r.type} ${r.name}: ${r.status}`))
})

el.addEventListener('dns-setup:verified', async (event) => {
  // A hint, not proof: re-check on your server before acting on it.
  await fetch('/onboarding/dns/recheck', { method: 'POST' })
})

el.addEventListener('dns-setup:provider-change', (event) => {
  analytics.track('dns_provider', event.detail) // { provider, detected, source }
})

Theming

The widget inherits font and color from your page, and most default colors are mixed from the inherited text color, so it adapts to light and dark pages. Set these custom properties on dns-setup to match your product:

PropertyDefaultUsed for
--dns-setup-accent#225bc1Links, buttons, focus rings
--dns-setup-accent-contrast#fffText on the primary button
--dns-setup-accent-subtleaccent at 10%Hover and active highlights
--dns-setup-backgroundCanvasContainer background
--dns-setup-subtle-backgroundtext color at 4%Record value cells
--dns-setup-popover-backgroundthe backgroundProvider picker list
--dns-setup-bordertext color at 16%Borders and dividers
--dns-setup-mutedtext color at 64%Secondary text
--dns-setup-success#137b70Verified statuses
--dns-setup-success-subtlesuccess at 12%The "detected" tag in the DNS host list
--dns-setup-warning#7e5712Statuses that need a change, and the "Blocked by CNAME" note
--dns-setup-radius11pxContainer corners
--dns-setup-control-radius6pxButtons, picker and cells
--dns-setup-padding24px (16px when the element is under 520px wide)Inner padding
--dns-setup-max-widthnoneContainer width cap
--dns-setup-shadownoneContainer shadow
--dns-setup-fontinheritFont
--dns-setup-mono-fontui-monospace, SFMono-Regular, Menlo, Consolas, monospaceRecord names and values

For anything else, style the exposed parts with ::part(): container, provider-heading, provider-picker, provider-popover, records, record-row, record-header, record-mapping, record-arrow, record-cell, code-value, placeholder-value, copy-button, notes, note-warning, status, actions, primary-button, secondary-button, handoff.

dns-setup {
  display: block;
  --dns-setup-accent: #4f46e5;
  --dns-setup-radius: 8px;
  --dns-setup-max-width: 720px;
}
@media (prefers-color-scheme: dark) {
  dns-setup {
    color: #e5e7eb;                       /* text inherits from the host */
    --dns-setup-background: #111827;
    --dns-setup-accent: #a5b4fc;
    --dns-setup-accent-contrast: #111827;
    --dns-setup-success: #5eead4;
    --dns-setup-warning: #fcd34d;
  }
}
dns-setup::part(primary-button) { font-weight: 600; }

Privacy and limits

What runs where

  • In the browser: the script and the DNS hosts’ icons load from https://dnsisbeautiful.com, and the widget sends POST {api-base}/api/dns-check with the domain, the entries (type, full name, value, remove, fallback) and the resolver list. No DNS queries run in the browser, the request is not credentialed (no cookies go to another site), and the widget stores nothing in the browser.
  • On the DNS is Beautiful server: for each entry, it queries the selected public resolvers, finds the zone’s nameservers (walking up from the name with CNAME and NS queries through Google DNS), queries up to 4 of those nameservers directly (at most 40 direct queries per check), and looks up whether the name is a CNAME: for an A or AAAA entry, on every selected resolver and every queried nameserver; for other entries, on the first selected resolver. For a root alias it looks up the root’s A and AAAA records on every selected resolver and queried nameserver, its CNAME record on each queried nameserver, and the target’s A and AAAA records on every selected resolver (a root alias removal too, plus the root’s CNAME record on every selected resolver). For dangling CNAMEs (names with an entry other than TXT or MX) it reads each declared name’s CNAME on the first selected resolver, asks every selected resolver once for each CNAME target’s A record, and, only for a target that is NXDOMAIN everywhere, follows its CNAME chain on the first resolver (at most 8 more lookups, within about 2 seconds). The DNS host is identified from the nameserver names.
  • Logged: for a check sent from another site, one line with your page’s origin, the shortest checked name, the number of entries, the outcome and the time taken.

Limits

  • 1 to 20 entries, 1 to 8 resolvers, a request body up to 64 KB. Each check times out after 15 seconds on the server (5 seconds per DNS query) and 45 seconds in the browser.
  • Automatic checks run immediately, then after 10 s, 15 s, 22.5 s and so on, capped at one a minute, and stop after 50 checks (about 45 minutes). “Check now” runs one more. A hidden tab pauses checking, and returning to it checks right away.
  • An invalid request (HTTP 400, 403, 413 or 415) stops checking and shows the error. Other failures retry on the schedule; an HTTP 429 waits a minute.
  • The API has no per-site rate limit.

CORS

/api/dns-check accepts cross-origin requests from any http or https origin (including localhost) without credentials. The script and the other files under /embed/ are served with Access-Control-Allow-Origin: *, which the integrity check needs.

Versions

  • 0.1.4 (current). Dangling CNAMEs: a CNAME at a declared name whose target is NXDOMAIN gets a stand-alone “Delete the CNAME record” row with a takeover warning, and dns-setup:verified waits until it’s gone. A CNAME removal at @ is now judged by the root’s addresses (a flattened ALIAS reported Removed before) and is headed with the DNS host’s alias type. Both are server-side checks, so earlier widget versions get the new verified and removal verdicts too, but without the new row or any explanation: such a widget can stay pending with every row verified until the CNAME is deleted.
  • 0.1.3. Root domain aliases: a CNAME entry at @ shows as the DNS host’s ALIAS or ANAME record, a flattened CNAME, or, on a host with no alias, the new optional fallback addresses. It is verified when the root’s addresses are the target’s (or exactly the fallback), and other root A and AAAA records get generated delete rows. The widget now sends domain with each check.
  • 0.1.2. Breaking: records is now a declaration. Entries are { type, name, value } or { …, remove: true }; action and match are rejected with a migration hint. The widget derives Add, Change and Delete steps per entry from its own lookups, including a generated “Delete the CNAME record” row for A and AAAA. SPF and DMARC are treated as one policy per name. TXT and MX are checked through a CNAME (DKIM, ACME) with a “Blocked by CNAME” note. Wildcard names (*, *.example.com). Event records carry remove: true instead of action. Styling: headings read “Add an A record” / “Change the A record”, rows lose their divider lines, provider notes use the body text color, and a new note-warning part.
  • 0.1.1. Remove rows read “Delete A record” with Still present / Removed statuses and show the record as reference text without Copy buttons. A blank root name shows as “blank (example.com)”. Name-entry notes are hidden on remove rows. Pending statuses use the warning color. New placeholder-value part.
  • 0.1.0. First release: <dns-setup> custom element with DNS host detection, per-host steps, live checks, the three events, custom properties and parts. Entries took action (add, remove, change).

0.1.0 and 0.1.1 files are still published, but they send action on every entry, which the current API rejects, so their live checks stop with an error. Move to 0.1.4.