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

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

Learn how to structure your fullstack application routes with `/internal`, `/api`, and `/*` for better separation and no CORS headaches.

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.html for 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.

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:

  • /internal keeps UI-driven logic flexible
  • /api keeps 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: