Guides

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.

None of these patterns require any changes to how RoExpress works. They are purely about how you organise your own files.

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",
}
Keeping routes in 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)
There is no single right style. Style A (table) works well when you want to inspect or iterate all routes for a system programmatically. Style B (Register function) works well when a system needs setup work before registering | opening a DataStore, connecting a Bridge listener, etc.

Which style should I use?

SituationRecommended
Small project, a few routes totalInline in Server.luau | no split needed
Multiple systems, routes shared between server and clientRoutes module in ReplicatedStorage
System needs setup before registering (DataStore, Bridge)Style B | Register(app)
You want to enumerate or test routes programmaticallyStyle A | table of handlers
Large team, each person owns a systemStyle 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