How to find broken links in WordPress without a plugin

WordPress has no built-in broken link report. You can still get a complete list without installing anything. Which method fits depends on what you can access: Search Console (anyone), a desktop crawler (anyone), or the command line (developers with SSH). Each finds a different slice, so this guide also covers what each one misses.

1. Google Search Console

If your site is verified in Search Console, Google has already collected the internal URLs it found returning 404.

  1. Open Search Console and choose your property.
  2. Go to Indexing → Pages and open Not found (404) under "Why pages aren't indexed".
  3. Click a URL and use Inspect URL to see where Google found it (referring pages).

Good for: dead internal URLs Google actually crawled. Misses: almost all outbound links to other sites, pages Google has not crawled yet, and broken images. Data can be weeks old.

2. A desktop crawler

A crawler visits your site like a visitor, follows every link and reports status codes. Screaming Frog SEO Spider is the best-known one; its free version crawls up to 500 URLs. Xenu's Link Sleuth (old but still works on Windows) and the open-source lychee CLI are free alternatives.

  1. Enter your home page URL and start the crawl.
  2. Filter the results by Client Error (4xx) and No Response, for both internal and external links.
  3. Use the "Inlinks" view to see which page contains each broken link.

Good for: anything rendered on the page, including theme and page-builder output. Misses: drafts, pages not linked from anywhere, members-only content. Larger sites hit the free 500-URL limit. A crawler also cannot edit anything: you still have to find and fix each link in WordPress.

3. WP-CLI + curl: check every link in the database

If you have SSH access, you can pull every link out of your content and check each unique URL once. This is the most complete no-plugin method for post content. Run it on a staging copy first if you are unsure.

# 1. Every link in published content, with the post ID
for id in $(wp post list --post_type=any --post_status=publish --field=ID); do
  wp post get "$id" --field=post_content \
    | grep -oE 'href="https?://[^"]+"' \
    | sed -E 's/^href="//; s/"$//' \
    | sed "s/^/$id\t/"
done | sort -u > links.tsv

# 2. Check each unique URL once (final status after redirects)
cut -f2 links.tsv | sort -u | while read -r url; do
  code=$(curl -s -o /dev/null -L --max-time 15 -A "Mozilla/5.0" -w '%{http_code}' "$url")
  printf '%s\t%s\n' "$code" "$url"
  sleep 0.5
done > status.tsv

# 3. The likely-broken ones (000 = no response / DNS failure)
awk -F'\t' '$1=="404" || $1=="410" || $1=="000"' status.tsv

Then look up where a URL lives with grep 'the-url' links.tsv and wp post list --post__in=ID --fields=ID,post_title,url.

Limits of this recipe: it only reads post_content with double-quoted hrefs, so images (src), menus, widgets, custom fields and page-builder data are not covered (see the next section). It runs one request at a time, so thousands of links take a while. Run it again for anything that returns 000 or 5xx: a one-off timeout is not a dead link.

4. Places links hide

Most "without a plugin" methods only search post content. In a typical WordPress site links also live in:

WhereStored inHow to list them
Menus (custom links)wp_postmeta, key _menu_item_urlwp menu list, then wp menu item list <menu> --fields=db_id,title,link
Classic and block widgetswp_options (widget_text, widget_block), serializedwp option get widget_block --format=json
Elementorwp_postmeta, key _elementor_data (JSON)wp post meta get <ID> _elementor_data
ACF and other custom fieldswp_postmetawp db query "SELECT post_id, meta_key, meta_value FROM $(wp db prefix)postmeta WHERE meta_value LIKE '%http%' AND meta_key NOT LIKE '\_%'"
Synced patterns (reusable blocks), navigation blockswp_posts with post types wp_block, wp_navigationAdd them to the --post_type list in the recipe above
Images and mediasrc attributes in contentChange the grep pattern to 'src="https?://[^"]+"'

Never edit serialized data (widgets) with a plain SQL REPLACE: changing the length of a string breaks the serialization. Use wp search-replace, which handles it, and run it with --dry-run first.

Reading the results correctly

Fixing what you find

When a plugin is worth it

The methods above are fine as a one-off audit. They become tedious when you want to know when a link breaks, or when your links live in menus, widgets and builder data. That is the job a link checker plugin does continuously. If you dislike plugins that send your site to a cloud service, choose one that checks from your own server; see local Broken Link Checker alternatives.

Want this done for you, on your own server?

LinkTender is a free WordPress plugin that does all of the above in the background: it finds links in posts, blocks, Elementor, shortcode builders such as Divi and WPBakery, custom fields, comments, menus and widgets (comments and shortcode builders from version 1.1), checks them from your server, marks bot-blocked links Unverified instead of broken, and lets you fix a URL everywhere it appears. No account, no link quota, and nothing is checked until you click Start checking links. Get it free on WordPress.org or read the docs.

Last updated: 13 October 2026