Amblem
Furkan Baytekin

Practical Routing with: `/internal`, `/api`, and `/*`

Practical routing guide for fullstack apps

Practical Routing with: `/internal`, `/api`, and `/*`
106
5 minutes

When you build a fullstack application, the backend ends up doing more than just exposing endpoints. It becomes responsible for serving the frontend, handling internal APIs, routing external integrations, securing resources, and making sure your SPA doesn’t break when someone refreshes the page.

A clean way to manage all this is to separate your routes into three layers:

This structure keeps the system predictable, splits responsibility clearly, and pairs perfectly with the “same-origin” strategy, helping you avoid CORS problems entirely.

In this post, we’ll look at why this routing model works, when it’s useful, potential drawbacks, and how to implement a minimal example using Go and chi as a demonstration.


Why Separate Routes This Way?

/internal - Private In-App API Surface

These endpoints are only used by your own frontend. They support things like:

They aren’t meant to be stable. They can change frequently because no external client depends on them.

Think of /internal as “whatever the UI needs right now.”


/api - Public or Stable External API

This is where integrations, mobile apps, or other services talk to your application.

These routes:

By splitting /api and /internal, you keep UI-driven and integration-driven logic from stepping on each other.


/* - Frontend Routing with Fallback

If you’re running a SPA (React, Vue, Svelte, etc.), the frontend needs to control routing. But the server still receives the request first.

For example:

/dashboard /settings/account /products/42

These are not real backend endpoints. They should all map to index.html so the browser can load your app and let the JavaScript router do its job.

Your server becomes responsible for:

This prevents the classic SPA refresh error where a deep link ends in 404 Not Found from the backend.


Why This Routing Model Works So Well

✔ Clear boundaries

You instantly know which responsibility each route carries.

✔ No accidental API collisions

The UI doesn’t leak into public integrations.

✔ No CORS headaches

If backend and frontend share the same origin, all requests-internal and external-stay inside one domain.

✔ Easy to scale

Public API and internal API can evolve independently.

✔ Great for monoliths and microservices

Monoliths get simplicity. Microservices get clean separation points.


Same-Origin Strategy (and Why You Should Use It)

The browser considers two URLs “same origin” if:

are identical.

Example:

Frontend: http://localhost:3000 Backend: http://localhost:3000

Same origin → no CORS at all.

If they differ even slightly:

Frontend: http://localhost:5173 Backend: http://localhost:3000

Different origin → browser enforces CORS rules.

When you serve your frontend from the backend itself, you eliminate the problem entirely:

/ |-- /internal/* |-- /api/* |-- /assets/* |-- index.html

The frontend talks to the backend without preflights, without weird headers, and without extra security configuration.


Disadvantages to Be Aware Of

✖ Backend and frontend are closely coupled

Scaling them independently becomes harder.

✖ Internal APIs can become messy

Since internal routes are flexible, they can grow in an unstructured way if the team isn’t disciplined.

✖ SPA fallback must be placed last

Otherwise you risk returning index.html for actual API endpoints.


Implementing This Routing Structure (Go + chi Example)

Here’s a minimal working demo of how to implement this routing architecture.

go
package main import ( "net/http" "path/filepath" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) func main() { r := chi.NewRouter() r.Use(middleware.Logger) // ----- INTERNAL ROUTES ----- r.Route("/internal", func(r chi.Router) { r.Get("/status", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"ok": true, "source": "internal"}`)) }) }) // ----- PUBLIC API ROUTES ----- r.Route("/api", func(r chi.Router) { r.Get("/hello", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"message": "Hello from API"}`)) }) }) // ----- FRONTEND STATIC ----- frontendDir := filepath.Join("frontend", "dist") fs := http.FileServer(http.Dir(frontendDir)) // Serve static frontend assets r.Handle("/assets/*", fs) // SPA fallback for everything else r.NotFound(func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Join(frontendDir, "index.html")) }) http.ListenAndServe(":3000", r) }

The key idea here isn’t the language or the router- it’s the routing order:

  1. Internal routes
  2. Public routes
  3. Static files
  4. SPA fallback

Any web server (Node, Go, Python, Ruby, Java, .NET) can use this same structure.


Final Thoughts

This routing model is simple but incredibly effective:

It works across languages, frameworks, and infrastructure choices.

Go + chi was just the example- the real takeaway is the routing architecture itself.

Whether you’re structuring Nest.js, Express, FastAPI, Laravel, Rails, or Go, this model stays practical and predictable.


Album of the blog:

Suggested Blog Posts