A while back I wrote about serving an Angular front end and a dynamic API from a single Apache virtualhost, using one rewrite rule to send any request that didn't resolve to a real file over to the API's front controller. Revisiting that config recently across a few production sites, I noticed a bug hiding in plain sight in that original post: the prose said unresolved requests should fall through to index.php (the API), but the rewrite rule I actually posted sent them to index.html (the Angular shell) instead. That wasn't a typo — it was the config quietly confessing a real limitation. A single catch-all rule can only pick one target. It can send everything to the SPA, or everything to the API, but not both, based on what the request actually is.

That's fine as long as the API and the SPA never both need to handle requests that don't correspond to real files on disk. In practice they both do: Angular's client-side router needs arbitrary paths like /gallery/foo/bar to resolve to index.html, and a REST API needs paths like /api/posts/42 to resolve to index.php. Neither of those is a real file. The old rule couldn't tell them apart.

The fix is to give the rewrite rules a second decision point: before falling back to the API's front controller, check whether the request path actually belongs to the API.

<Directory /var/www/my_project/public>
    # 1. real files/symlinks/dirs served as-is
    RewriteCond %{REQUEST_FILENAME} -s [OR]
    RewriteCond %{REQUEST_FILENAME} -l [OR]
    RewriteCond %{REQUEST_FILENAME} -d
    RewriteRule ^.*$ - [NC,L]

    # 2. /api/* -> the API's front controller
    # (only these two rules are gated)
    RewriteCond %{REQUEST_URI} ^/api/
    RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$
    RewriteRule ^(.*) - [E=BASE:%1]

    RewriteCond %{REQUEST_URI} ^/api/
    RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L]

    # 3. everything else -> Angular SPA shell
    RewriteRule ^ index.html [NC,L]
</Directory>

Rule 1 is unchanged from before: if the request maps to a real file, symlink, or directory, serve it as-is and stop.

Rule 2 is new. It's gated behind a RewriteCond %{REQUEST_URI} ^/api/ check on both of its rewrite rules, so it only fires for requests under /api/. The BASE-variable trick (the %{REQUEST_URI}::$1 condition) is still there for the same reason as before — it keeps things working if you're using Apache aliases for mass virtual hosting — but now it's scoped to just the API prefix instead of silently applying to every request on the site regardless of whether it was meant for the API.

Rule 3 is the fallback, and it's simpler than before: anything that isn't a real file and isn't under /api/ gets index.html, full stop. No BASE variable needed here, because Angular's router doesn't care about a mod_alias base path the way a PHP front controller does — it just needs the shell document.

One nice side effect of scoping rule 2 to a prefix: you get to choose that prefix. Anything you don't want treated as an API route just doesn't start with /api/, and it falls straight through to the SPA shell instead. The deployment story from the original post — rsync excluding index.php, favicon.ico, and robots.txt from the sync of the Angular build — doesn't change at all. It's still the same three files reserved for the API; they're just reachable in a way that's actually correct now.