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:
-
/internal/*- in-app APIs for your own frontend -
/api/*- public API for external consumers -
/*- frontend routes, handled by a static fallback (can even be React)
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:
- dashboard data
- settings pages
- authenticated user flows
- admin utilities
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:
-
are versioned (
/api/v1/...) - follow stable contracts
- are safe to expose publicly
- require stricter validation
- rarely change without a new version
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:
- serving static assets
-
returning
index.htmlfor any non-API route - letting the SPA take over
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:
- protocol
- domain
- port
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.
gopackage 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:
- Internal routes
- Public routes
- Static files
- 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:
-
/internalkeeps UI-driven logic flexible -
/apikeeps your integrations stable - SPA fallback makes frontend routing behave correctly
- same-origin setup keeps CORS out of the picture
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:




