|Docs

Configure Client-Side Routing for Single-Page Applications

frontendsparoutingcaddy

Single-page applications (SPAs) built with React, Vue, Angular, or Solid use client-side routing to navigate between pages without full reloads. When deployed to Railway, the web server needs to be configured to return index.html for all routes so the JavaScript router can handle navigation.

Without this configuration, refreshing the browser on a route like /dashboard returns a 404 because the server has no file at that path.

This guide covers how to configure fallback routing with Caddy, Nginx, or a Node-based server when deploying a SPA to Railway.

The problem

SPAs use the browser's History API to change the URL without making a server request. When a user navigates from / to /dashboard, the JavaScript router updates the URL and renders the correct component.

The issue occurs when the browser sends a request directly to /dashboard, either from a page refresh, a bookmarked link, or a shared URL. The server looks for a file at /dashboard, finds nothing, and returns a 404.

The fix is a fallback rule: serve the requested file if it exists, otherwise serve index.html and let the client-side router take over.

One-click deploy with Caddy

If you want a ready-made Caddy setup for serving static files and SPAs, deploy the Caddy template and customize the Caddyfile:

Deploy on Railway

It is recommended to eject from the template to get a copy of the repo on your GitHub account.

Caddy is a lightweight web server that works well for serving SPAs on Railway. Most Railway SPA templates use Caddy.

Create a Caddyfile in your project root:

Key lines:

  • root * dist sets the directory containing your build output. Change dist to public, build, or out depending on your framework.
  • try_files {path} /index.html serves the file if it exists, otherwise falls back to index.html.
  • encode gzip enables compression for faster delivery.
  • auto_https off is required because Railway handles TLS termination.
  • trusted_proxies static private_ranges 100.0.0.0/8 ensures Railway's proxy headers are trusted.

Build output directories by framework

FrameworkBuild commandOutput directory
React (Vite)npm run builddist
Vue (Vite)npm run builddist
Angularng builddist/<project-name>/browser
Solid (Vite)npm run builddist
Gatsbygatsby buildpublic

Update the root directive in your Caddyfile to match your framework's output directory.

Option 2: Nginx

If you prefer Nginx, create an nginx.conf in your project root:

Use a multi-stage Dockerfile to build and serve:

Nginx uses the $PORT variable via its envsubst template system when placed in /etc/nginx/templates/.

Option 3: Node-based serving

For simpler setups, you can serve your SPA with a Node server. This avoids a separate web server but uses more memory.

Install the serve package:

Add a start script to your package.json:

The -s flag enables SPA mode, which rewrites all requests to index.html.

Multi-stage Dockerfile with Caddy

This is the recommended approach for production deployments. Build your app with Node, then serve with Caddy:

Adjust COPY --from=build /app/dist ./dist to match your framework's output directory.

Route API requests alongside the SPA

If your SPA calls a backend API running as a separate Railway service, you can proxy API routes through Caddy to keep the frontend and API on the same domain.

Add a reverse_proxy directive to your Caddyfile:

Replace backend.railway.internal with your backend service's private networking hostname.

When you do not need fallback routing

You do not need try_files configuration if:

  • You use hash routing (e.g., /#/dashboard). Hash-based URLs never hit the server because the fragment stays in the browser.
  • Your framework handles SSR. Frameworks like Next.js, Nuxt, Remix, SvelteKit, and Astro (in SSR mode) run a server that handles all routes. Deploy these as Node services, not as static files behind Caddy.

Common pitfalls

Assets return index.html instead of the actual file. This happens when file_server is missing from the configuration. Caddy uses a predefined directive order where try_files (a rewrite) runs before file_server, so real files are served first when both directives are present.

Nested routes load broken assets. If your app is at /app/settings and loads a relative script like src="main.js", the browser requests /app/main.js instead of /main.js. Fix this by setting a base path in your build tool (e.g., base: "/" in vite.config.js).

Health checks conflict with SPA routes. The rewrite /health /* line in the Caddyfile ensures Railway's health check endpoint always returns a response. Without it, /health would fall through to index.html, which may not return the status code Railway expects.

Next steps