Skip to content
  • FG-37
  • 14 min read
  • Last checked November 8, 2026

How to Fix WordPress Pages Returning 404 (Only the Homepage Works)

Your homepage loads but every other page says 404 Not Found. The server is not handing pretty URLs to WordPress. Fix .htaccess, mod_rewrite, Nginx try_files or the rewrite rules.

Run the health check

Your homepage loads normally, but click any page, post or category and you get 404 Not Found. On Apache it often reads Not Found. The requested URL was not found on this server. On Nginx it is a bare white page that says 404 Not Found with nginx underneath. On LiteSpeed it is a plain error page with the LiteSpeed name at the bottom. Sometimes it is your theme’s own “Page not found” template instead, which points to a different cause.

Your content is almost certainly safe. Pretty addresses such as /about-us/ are not real folders on the server. They only work because the web server quietly hands every unknown address to WordPress’s index.php, and WordPress then works out which page you meant. When that handoff breaks, the homepage still works (it is index.php itself) and everything else fails. This usually appears right after a migration, a move to a new host or server type, a restore from backup, or a change to the Permalinks setting.

What is happening

A request for https://example.com/about-us/ passes through two separate layers, and either one can produce the 404.

  1. The web server layer. Apache or LiteSpeed reads the rules in .htaccess. Nginx reads its own config files (it ignores .htaccess completely). The rule says: if the requested address is not a real file or folder, run /index.php instead. If this rule is missing or ignored, the server looks for a folder called about-us, finds nothing, and returns its own plain 404 without WordPress ever running.
  2. The WordPress layer. Once index.php runs, WordPress compares the path against a list of patterns stored in the database option rewrite_rules. A matching pattern turns about-us into a query like “the page with slug about-us”. If the list is stale or two patterns collide, WordPress runs fine but shows your theme’s 404 template.

Telling these two apart first saves a lot of time:

What the 404 looks like Which layer failed Go to
Plain server page (Apache, nginx or LiteSpeed branding, no theme design) Web server never handed the request to WordPress Steps 2 to 4 and Step 7
Your theme’s styled “Page not found” on every page WordPress ran but its rewrite rules are stale or empty Step 5
Theme 404 on one section only (a post type, a category, a single page) A slug collision or a plugin’s rules Step 6
Everything 404s inside a subfolder install such as /blog/ Wrong base path in the rules Step 7

Redirect loops are a different problem, covered in the ERR_TOO_MANY_REDIRECTS guide (FG-03).

Before you start

  • Access you need: SFTP or your hosting file manager (with hidden files shown), the WordPress dashboard if you can reach it, and ideally SSH. Server config changes (Steps 3 and 4) need root access on your own server; on shared or managed hosting you open a ticket for those instead.
  • Back up first. Download a copy of the current .htaccess (even an empty one) and any Nginx config file before you edit it, so you can put it back in seconds.
  • How to run commands. Commands starting with wp, ls, apachectl, nginx or systemctl run in an SSH terminal on the server. Run wp commands from the site’s folder, the one that contains wp-config.php; on some hosts the command is wp-cli instead of wp. Commands starting with curl run on your own computer: macOS and Linux include curl, and Windows 10 and 11 include curl.exe in PowerShell (type curl.exe, not curl, which PowerShell treats as a different command), or you can use WSL. Blocks that start with #, <IfModule, <Directory or location are not commands: they are lines in a config file you edit.
  • Test without caches. Use curl or a private window, and purge page caches and your CDN after each fix.

Step 1: Confirm the content exists and see who answered

The plain permalink test

WordPress can always load a page through its plain query address, which needs no rewriting. Find a page ID (in the dashboard, hover a page title in Pages and read post= in the link), or over SSH:

wp post list --post_type=page --fields=ID,post_name,post_status

Type it as shown. --post_type=page limits the list to pages, and --fields chooses the columns. Good output is a table of IDs and slugs with status publish. An empty table means there are no pages, which is a database or migration problem rather than a rewriting one. Now open this address in a private window:

https://example.com/?page_id=POST_ID

Replace example.com with your domain and POST_ID with an ID from the list (use ?p=POST_ID for a blog post). If the page loads, WordPress and the database are fine and the problem is purely URL rewriting.

Read the response headers

On your own computer:

curl -sI https://example.com/SAMPLE-PAGE/

Replace SAMPLE-PAGE with the slug of a real page. -s hides the progress meter and -I fetches headers only. Look at three things:

  • The first line shows the status, such as HTTP/2 404.
  • The server: header names the software answering (for example Apache, nginx, LiteSpeed or cloudflare).
  • A link: header containing rel="https://api.w.org/" means WordPress built the response. If that header is missing on a 404 but present on your homepage, the server answered alone, and the problem is in Steps 2 to 4.

Step 2: Restore a missing or blank .htaccess (Apache and LiteSpeed)

This is the most common cause after a migration. Files whose names start with a dot are hidden, and many FTP clients, zip tools and copy commands skip them. The site arrives without .htaccess, or with an empty one, and only the homepage works.

Confirm it

ls -la .htaccess && cat .htaccess

Run this over SSH in the site folder. -l shows details and -a includes hidden files. Bad output is No such file or directory, a size of zero, or contents with no # BEGIN WordPress section. In a file manager, switch on “show hidden files” (often in a settings menu) before you decide the file is missing.

Fix it

Create or edit .htaccess in the site folder so it contains the standard WordPress block for a site installed at the domain root:

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

There are no placeholders: paste it as shown. RewriteBase / says the site lives at the root. The two RewriteCond lines mean “only if the request is not a real file (!-f) and not a real folder (!-d)”, so images and CSS are served directly. The last rule sends everything else to /index.php, and [L] stops processing there. Keep any other blocks, such as a caching plugin’s, but leave them outside the WordPress markers, because WordPress rewrites everything between # BEGIN WordPress and # END WordPress when permalinks are saved.

Permissions of 644 are normal. If the file belongs to a different user than the site runs as (common after copying as root), WordPress cannot update it.

Let WP-CLI write it

wp rewrite flush --hard

Type it as shown. --hard asks WP-CLI to regenerate .htaccess as well as the database rules. It prints Success: Rewrite rules flushed. even when it could not write the file, so check with cat .htaccess afterward. WP-CLI cannot detect Apache modules from the command line, so if the file is still not written, add these lines to a file named wp-cli.yml in the site folder and run the flush again:

apache_modules:
  - mod_rewrite

Paste as shown. It tells WP-CLI to assume mod_rewrite is available. The hard flush works on single sites only, not multisite networks, and is pointless on Nginx.

Step 3: Check that Apache reads .htaccess and has mod_rewrite

A perfect .htaccess does nothing if Apache ignores it. This bites people moving to their own VPS, because the stock Apache 2.4 setting is AllowOverride None and mod_rewrite is often off.

The deliberate typo test

Add a line of nonsense such as THIS_IS_A_TEST at the very top of .htaccess and load the homepage. If you get 500 Internal Server Error, Apache is reading the file: remove the line and move on to Step 5. If the homepage loads normally, Apache is ignoring .htaccess. Remove the line either way.

Fix AllowOverride (your own server)

apachectl -M | grep rewrite
grep -Rn "AllowOverride" /etc/apache2/ /etc/httpd/ 2>/dev/null

Run over SSH as root. The first line lists loaded modules and keeps the rewrite line: good output is rewrite_module (shared), and no output means it is not loaded. The second searches the config folders (/etc/apache2/ on Debian and Ubuntu, /etc/httpd/ on RHEL family systems) and prints each AllowOverride line with its file and line number (-n); 2>/dev/null hides errors for the folder that does not exist. On Debian or Ubuntu, enable the module with a2enmod rewrite. Then find the <Directory> block for your document root and make it look like this:

<Directory /var/www/DOCUMENT_ROOT>
    Options FollowSymLinks
    AllowOverride All
    Require all granted
</Directory>

DOCUMENT_ROOT is the path to the folder containing wp-config.php, as set by the DocumentRoot line in your virtual host file. AllowOverride All lets .htaccess use rewrite rules (the narrower AllowOverride FileInfo is enough for rewriting alone, but security and cache plugins often need more). mod_rewrite in .htaccess also needs FollowSymLinks or SymLinksIfOwnerMatch. Check the syntax with apachectl configtest (good output: Syntax OK), then apply it with systemctl reload apache2 (or httpd).

On shared hosting you cannot change these settings. If the typo test shows .htaccess being ignored there, open a ticket with the result.

Step 4: Add try_files on Nginx

If the server: header said nginx and there is no Apache behind it, .htaccess is irrelevant, however correct it is. Nginx needs its own instruction to fall back to WordPress.

Confirm it

nginx -T 2>/dev/null | grep -n "try_files"

Run over SSH as root. -T prints the full loaded configuration, and grep finds every try_files line. Good output includes a line ending in /index.php?$args; inside your site’s server block. Bad output is nothing, or a line such as try_files $uri $uri/ =404;, which is the stock default and causes exactly this symptom.

Fix it

In your site’s server { } block, edit the location / block:

location / {
    try_files $uri $uri/ /index.php?$args;
}

There are no placeholders; the $ words are filled in by Nginx. $uri tries the exact file, $uri/ tries a folder, and if neither exists the request goes to /index.php with the original query string ($args) attached, so search and tracking parameters still work. Your separate location ~ \.php$ block then passes it to PHP-FPM as usual. Test and apply:

nginx -t && systemctl reload nginx

nginx -t checks the syntax; good output ends with test is successful, and the reload only runs if the test passes. On managed Nginx hosting, ask your host to apply the standard WordPress try_files rule; they usually have a toggle or template for it.

Step 5: Flush the WordPress rewrite rules

If the 404 is your theme’s own template, the server is doing its job and WordPress’s list of patterns is the problem. It can be empty after an import, stale after a plugin added a post type, or cached in an object cache copied from another site.

The dashboard way

Go to Settings, Permalinks and click Save Changes without changing anything. Visiting that screen and saving rebuilds the rules.

The WP-CLI way, with checks

wp option get permalink_structure
wp rewrite list --format=count
wp rewrite flush
wp cache flush

Type these as shown. The first prints your structure, such as /%postname%/; an empty answer means plain permalinks are selected. The second counts the stored patterns: a normal site has well over a hundred, and 0 means the list is empty. wp rewrite flush rebuilds the list (good output: Success: Rewrite rules flushed.). wp cache flush empties the object cache, such as Redis or Memcached, which can otherwise keep serving the old, empty list; on a site with no persistent object cache it is harmless. To set the structure from the command line, use wp rewrite structure '/%postname%/' --hard, keeping the quotes.

Step 6: Fix slug collisions between pages and post types

If only one section 404s, look for two things claiming the same address. Typical examples: a page called portfolio and a custom post type whose archive is also /portfolio/, or a page called blog while the category base is blog.

Confirm it

wp rewrite list --match=/SECTION-SLUG/ --fields=match,query,source

Replace SECTION-SLUG with the path that fails, such as /portfolio/ or /portfolio/PROJECT-NAME/. --match shows only the patterns that would match that address, in the order WordPress tries them, and source shows where each came from (post, page, a taxonomy or a post type name). The first row wins. If the first row’s query points at a post type when you expected a page (or the reverse), you have found the collision. Then check what holds the slug:

wp post list --post_type=any --post_status=any --name=SECTION-SLUG --fields=ID,post_type,post_status

Replace SECTION-SLUG with the slug alone, without slashes. --post_type=any and --post_status=any include every type and status, including drafts. More than one row, or an unexpected type such as attachment, explains the clash.

Fix it

Rename one side. Change the page slug in Quick Edit, or change the post type’s slug in the plugin that registers it (most post type plugins have a “rewrite slug” field), then save permalinks again.

Step 7: Subdirectory installs and RewriteBase

If WordPress lives in a subfolder such as https://example.com/blog/, the rules must name that folder. Moving between a subfolder and the root often leaves the old path behind.

For a site served from /blog/, the .htaccess in the blog folder needs:

RewriteBase /SUBFOLDER/
RewriteRule . /SUBFOLDER/index.php [L]

Replace both SUBFOLDER tokens with the folder name, such as blog, keeping the slashes. These two lines replace the matching lines in the standard block from Step 2. If the site moved to the root, set them back to / and /index.php. On Nginx, the equivalent is a separate block:

location /SUBFOLDER/ {
    try_files $uri $uri/ /SUBFOLDER/index.php?$args;
}

Again replace SUBFOLDER with the folder name. A special case is “giving WordPress its own directory”, where visitors use the root but the core files sit in a folder such as /wp/. There, the .htaccess and the small index.php live in the root, RewriteBase stays /, and the siteurl option points at the subfolder while home points at the root. Check both with wp option get siteurl and wp option get home.

Step 8: LiteSpeed specifics

LiteSpeed Web Server, which many hosts run in place of Apache, reads .htaccess and understands Apache rewrite syntax, so Steps 2, 5 and 7 apply unchanged. A few details differ:

  • OpenLiteSpeed (the free edition) only reads the rewrite rules from .htaccess, and only if “Auto Load from .htaccess” is enabled in the WebAdmin console (under the server’s or virtual host’s Rewrite settings). It loads a folder’s .htaccess once and needs a graceful restart before later edits take effect, so a fixed file can look like it “did nothing”. On your own server, run /usr/local/lsws/bin/lswsctrl restart after editing. On hosting, open a ticket.
  • The LiteSpeed Cache plugin writes its own blocks into .htaccess, marked # BEGIN LSCACHE and # BEGIN NON_LSCACHE. These are normal. Make sure the # BEGIN WordPress block is still present after them and that nothing was pasted inside it.
  • Cached 404s. LiteSpeed’s server-level page cache can store a 404 response. After fixing the rules, use LiteSpeed Cache, Toolbox, Purge All, or run wp litespeed-purge all if the plugin’s WP-CLI commands are available, then test again.

Advanced: Tracing how a URL becomes a query

When the cause is not obvious, follow one request all the way through each layer instead of guessing.

Watch Apache rewrite decisions

On your own Apache 2.4 server you can make mod_rewrite log every decision. Add this inside your site’s virtual host, reload Apache, request one failing URL, then remove it:

LogLevel alert rewrite:trace3

No placeholders. It raises logging for mod_rewrite only, to trace level 3, while keeping everything else quiet. Read the site’s error log (often /var/log/apache2/error.log or /var/log/httpd/error_log) and look for lines containing [rewrite:trace. A healthy request shows RewriteCond: input='...' pattern='!-f' => matched followed by rewrite '...' -> '/index.php'. If you see no rewrite lines at all for the request, Apache never read the rules, which takes you back to Step 3. Remove it afterward: trace logging fills disks fast.

Watch WordPress match the rule

Install the free Query Monitor plugin, visit a failing URL while logged in, and open its Request panel. It shows the matched rewrite rule, the resulting query (such as pagename=about-us) and the query variables WordPress ended up with. A wrong match (such as attachment=about-us) points at Step 6; no match at all often means a disabled plugin used to register that post type.

When to contact your host

Open a ticket if the deliberate typo test shows .htaccess is ignored, if the server is Nginx and you cannot edit its config, if you are on OpenLiteSpeed without restart access, or if the 404 persists with a correct .htaccess and freshly flushed rules. Include:

  • One failing URL and one working URL, and the time you tested (in UTC).
  • The output of curl -sI for the failing URL, including the server: header.
  • The result of the plain permalink test (?page_id=POST_ID works or not).
  • The contents of your .htaccess, and the result of the typo test.
  • What changed just before it started: a migration, a restore, a new server type, or a permalink change.

Still stuck?

Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.

Get the free checklists

Keep reading

All field guides

Search the field manual

What is going wrong?

Press / to search from any page, Esc to close