Skip to content

Basics

Routing

Define the URL surface of your app in config/routes.cfm. This page shows how to wire simple routes, resource routes, nested resources, namespaced sections, and pattern constraints — then how to call the matching helpers from your views and controllers.

You’ll learn:

  • How to define a single route with get, post, patch, delete
  • How resources() produces the seven REST actions in one line
  • How to nest resources and namespace an admin section
  • How to constrain placeholder patterns with regex
  • Which helper reads which route at call time

config/routes.cfm is a plain CFM file loaded at boot. Wrap the mapper DSL in <cfscript>; call mapper(), chain matchers, and close with .end(). The minimum useful route is a single get with a name, a pattern, and a controller#action target.

<cfscript>
mapper()
.get(name="hello", pattern="/hello", to="main##hello")
.wildcard()
.end();
</cfscript>

name="hello" is the handle your helpers pass — linkTo(route="hello") builds the URL, redirectTo(route="hello") navigates to it. pattern="/hello" is the URL shape. to="main##hello" is the double-hash form of controller##action (two hashes because CFML treats a single # as an expression delimiter). .wildcard() is the catch-all that always comes last.

resources() is where most routing happens. One call generates routes for the seven REST actions plus the named helpers that go with them.

<cfscript>
mapper()
.resources(name="posts", binding=true)
.root(to="posts##index", method="get")
.wildcard()
.end();
</cfscript>

This gives you GET /posts, GET /posts/new, POST /posts, GET /posts/:key, GET /posts/:key/edit, PATCH/PUT /posts/:key (both verbs map to update), and DELETE /posts/:key — plus the named routes posts, newPost, post, and editPost. Each route is also registered with a .[format] twin (/posts.json, /posts/:key.xml, …), so the actual table holds more rows than seven. binding=true turns on route model binding: the member actions (show, edit, update, delete) see a pre-loaded params.post instance without calling findByKey themselves.

Skip the actions you don’t need. Pass only="..." to allow-list, or except="..." to deny-list.

<cfscript>
mapper()
.resources(name="comments", only="create")
.resources(name="posts", except="delete")
.wildcard()
.end();
</cfscript>

comments gets one route (POST /comments). posts gets six (every REST action except DELETE /posts/:key). The named routes that correspond to trimmed-away actions don’t exist — linkTo(route="newComment") would throw Wheels.RouteNotFound in the first example.

To build a URL like /posts/:postKey/comments, nest the inner resource via the callback argument. Wheels passes the mapper in; call .resources() on that parameter, not on the outer mapper().

<cfscript>
mapper()
.resources(name="posts", callback=function(map) {
map.resources(name="comments", only="create");
})
.wildcard()
.end();
</cfscript>

The nested resource produces POST /posts/:postKey/comments → comments##create with named route postComments. Inside the Comments controller, the parent key is available as params.postKey. Rails-style inline blocks (.resources("posts", function(r) { ... })) are not supported — use the callback= named argument.

Group a set of controllers under a shared URL prefix and a subfolder. .namespace("admin") prefixes URLs with /admin and loads controllers from app/controllers/admin/. Declare the namespaced routes between the .namespace() call and a matching .end():

<cfscript>
mapper()
.namespace("admin")
.resources("posts")
.end()
.resources("posts")
.wildcard()
.end();
</cfscript>

URLs become /admin/posts, /admin/posts/:key, and so on; the controller class is admin/Posts.cfc. The outer .resources("posts") still serves the public /posts URLs — namespaces don’t replace the non-namespaced routes, they sit alongside them.

namespace() (and scope(), package(), controller()) also accept a callback= argument, mirroring resources(): the routes declared inside the callback register under the namespace, and the scope closes automatically when the callback returns — no .end() needed:

<cfscript>
mapper()
.namespace(name="admin", callback=function(map) {
map.resources("posts");
})
.resources("posts")
.wildcard()
.end();
</cfscript>

Controllers that live in app/controllers/admin/ must extend the base controller with its full path — extends="app.controllers.Controller". The bare extends="Controller" fails to resolve from the subfolder on Lucee (invalid component definition, can't find component [Controller]).

Pattern placeholders are written as [name]. By default a placeholder matches any string without a slash or dot ([^\./]+ — dots are reserved for the optional .[format] suffix). Pass a constraints struct to restrict a placeholder to a regex.

<cfscript>
mapper()
.get(
name="postArchive",
pattern="/posts/[year]/[month]",
to="posts##archive",
constraints={year="\d{4}", month="\d{2}"}
)
.resources("posts")
.wildcard()
.end();
</cfscript>

With the constraint, /posts/2026/04 matches and fills params.year="2026", params.month="04". /posts/foo/bar does not match — the router moves on to the next route. Without the constraint, /posts/foo/bar would match and params.year="foo".

Every route with a name is reachable by name from anywhere in your app. The helpers take the name, resolve it to a URL, and fill in placeholders from named arguments.

  • linkTo(route="post", key=post.id, text="View") — renders an <a href="/posts/123">
  • urlFor(route="newPost") — returns the URL string
  • redirectTo(route="posts") — 302 redirect in a controller action
  • buttonTo(route="post", key=post.id, text="Delete", method="delete") — small form for non-GET verbs
  • startFormTag(route="post", key=post.id, method="put") — opens a form that posts to the route

Rename the resource once in routes.cfm and every helper updates. See Forms and Form Helpers for startFormTag examples in context.

When a route isn’t matching what you expect, print the registered route table. wheels routes shows every route’s method, path, controller#action, and name. It reads the table from your running app — with no server up, it refuses and tells you to wheels start first:

Terminal window
wheels routes

Start the server, then run wheels routes in your app directory to see the full table.