Route Organisation
As a project grows, putting every route and handler in one script becomes hard to read and harder to maintain. This guide shows several ways to split them out | a routes module for strings, handler modules per system, and two styles for writing the handlers themselves.
The baseline problem
A small game might start with this and it's fine:
-- ServerScriptService/Server.luau
local app = RoExpress.GetApp()
app:Get("shop/items", function(req, res)
res:Send(ShopData.GetItems())
end)
app:Post("shop/buy/:itemId=string", function(req, res)
-- 30 lines of purchase logic
end)
app:Get("player/:userId=number/stats", function(req, res)
-- 20 lines of stats logic
end)
-- ... 15 more routes ...
Once you have more than a handful of routes the file becomes a scrolling wall. Finding a specific handler means searching. Changing a route string means hoping you got every reference.
Step 1 | a routes module
Move every route string into one module. Every other file imports from here instead of using raw strings.
-- ReplicatedStorage/Routes.luau
return {
-- shop
SHOP_ITEMS = "shop/items",
SHOP_BUY = "shop/buy/:itemId=string",
SHOP_SELL = "shop/sell/:itemId=string",
-- player
PLAYER_STATS = "player/:userId=number/stats",
PLAYER_SAVE = "player/:userId=number/save",
-- combat
COMBAT_HIT = "combat/hit",
COMBAT_RELOAD = "combat/reload",
}
ReplicatedStorage means both the server and client can require the same module. The client uses the same constants when calling network:Get(Routes.SHOP_ITEMS) | no string duplication, no typos.The server now registers against constants:
local Routes = require(game.ReplicatedStorage.Routes)
app:Get(Routes.SHOP_ITEMS, function(req, res)
res:Send(ShopData.GetItems())
end)
Step 2 | handler modules
Move the logic for each system into its own module. There are two common styles.
Style A | table of handlers
Export a table where each key maps to a handler function. Clean to iterate, easy to see the full surface of a system at once.
-- ServerScriptService/Handlers/ShopHandlers.luau
local Routes = require(game.ReplicatedStorage.Routes)
local ShopData = require(script.Parent.Parent.ShopData)
return {
[Routes.SHOP_ITEMS] = { method = "Get", handler = function(req, res)
res:Send(ShopData.GetItems())
end },
[Routes.SHOP_BUY] = { method = "Post", handler = function(req, res)
local ok, err = ShopData.Buy(req.player, req.params.itemId)
if not ok then
res:Status(400):Error(err)
return
end
res:Status(200):Send()
end },
[Routes.SHOP_SELL] = { method = "Post", handler = function(req, res)
local ok, err = ShopData.Sell(req.player, req.params.itemId)
if not ok then
res:Status(400):Error(err)
return
end
res:Status(200):Send()
end },
}
The main server script iterates and registers everything in a loop:
-- ServerScriptService/Server.luau
local app = RoExpress.GetApp()
local ShopHandlers = require(script.Handlers.ShopHandlers)
for route, entry in ShopHandlers do
app[entry.method](app, route, entry.handler)
end
Style B | register function
Export a single Register(app) function. The module owns its own registration | the server script just calls it. Simpler to write, slightly less flexible to inspect.
-- ServerScriptService/Handlers/CombatHandlers.luau
local Routes = require(game.ReplicatedStorage.Routes)
local CombatData = require(script.Parent.Parent.CombatData)
local CombatHandlers = {}
function CombatHandlers.Register(app)
app:Post(Routes.COMBAT_HIT, function(req, res)
local damage = CombatData.ValidateHit(
req.player,
req.body.targetId,
req.body.timestamp
)
if not damage then
res:Status(400):Error("Invalid hit")
return
end
res:Send({ damage = damage })
end)
app:Post(Routes.COMBAT_RELOAD, function(req, res)
CombatData.Reload(req.player)
res:Status(200):Send()
end)
end
return CombatHandlers
-- ServerScriptService/Server.luau
local app = RoExpress.GetApp()
local CombatHandlers = require(script.Handlers.CombatHandlers)
local ShopHandlers = require(script.Handlers.ShopHandlers)
CombatHandlers.Register(app)
ShopHandlers.Register(app) -- or use the loop style from Style A
Putting it all together
A project using both patterns might look like this on disk:
ServerScriptService/
└── Server.luau -- requires app, registers middleware, calls each Register()
└── Handlers/
├── ShopHandlers.luau -- Style A: table
├── CombatHandlers.luau -- Style B: Register(app)
└── PlayerHandlers.luau -- Style B: Register(app)
ReplicatedStorage/
├── Routes.luau -- all route strings as named constants
└── RoExpress/ -- the framework
StarterPlayerScripts/
└── Client.luau -- requires Routes, calls network:Get(Routes.SHOP_ITEMS)
Which style should I use?
| Situation | Recommended |
|---|---|
| Small project, a few routes total | Inline in Server.luau | no split needed |
| Multiple systems, routes shared between server and client | Routes module in ReplicatedStorage |
| System needs setup before registering (DataStore, Bridge) | Style B | Register(app) |
| You want to enumerate or test routes programmatically | Style A | table of handlers |
| Large team, each person owns a system | Style B per system | each module is self-contained |
See also
MVC Pattern | taking organisation further with models and controllers · Middleware | adding guards without touching handlers · App | full route registration API