AJAX Basics: How Drupal Routes AJAX-Enabled Pages and Formsfor Drupal 11 , and 10

Last updated :  

In the last topic you saw AJAX show up for the first time, briefly, at the end of the Form API lessons — a dropdown that updated itself without a page reload. That felt a little like magic. In this topic we're going to open the hood and understand exactly how Drupal makes that happen, starting with the one thing every single AJAX interaction depends on: a properly declared route.

We'll be working from the official ajax_example module, which is Drupal Examples' dedicated playground for every AJAX pattern you're likely to need — dynamic dropdowns, auto-generated form fields, wizards that gracefully degrade without JavaScript, and more. This lesson looks at its routing file, which declares every one of those demos as a real, visitable URL.

What you'll learn in this lesson

  • How a single module can expose many AJAX-driven pages and forms through one routing file
  • Drupal's {nojs} pattern — the trick that lets an AJAX interaction gracefully fall back to a normal page load when JavaScript is unavailable
  • The difference between a route that hands off to a controller and one that hands off to a Form API class
  • Why a dedicated AJAX callback route looks slightly different from a normal page route
Quick refresher: if routing.yml files are new to you, go back to Routing (routing.yml) in Module Basics first — this lesson assumes you already know what path, defaults, and requirements do. What's new here is everything AJAX-specific layered on top of that foundation.

The source file

Path (relative to the Examples module's root): modules/ajax_example/ajax_example.routing.yml

ajax_example.description:
  path: '/examples/ajax-example'
  defaults:
    _controller: '\Drupal\ajax_example\Controller\AjaxExampleController::description'
    _title: 'AJAX Example'
  requirements:
    _permission: 'access content'

ajax_example.simplest:
  path: '/examples/ajax-example/simplest'
  defaults:
    _form: '\Drupal\ajax_example\Form\Simplest'
    _title: 'Simplest AJAX example'
  requirements:
    _permission: 'access content'

ajax_example.auto_text_fields:
  path: '/examples/ajax-example/auto-text-fields'
  defaults:
    _form: '\Drupal\ajax_example\Form\AutoTextFields'
    _title: 'Adds new text fields via AJAX'
  requirements:
    _permission: 'access content'

ajax_example.submit_driven_ajax:
  path: '/examples/ajax-example/submit-driven-ajax'
  defaults:
    _form: '\Drupal\ajax_example\Form\SubmitDriven'
    _title: 'Submit-driven AJAX'
  requirements:
    _permission: 'access content'

ajax_example.dependent_dropdown:
  path: '/examples/ajax-example/dependent-dropdown/{nojs}'
  defaults:
    _form: '\Drupal\ajax_example\Form\DependentDropdown'
    _title: 'Dependent dropdown'
    nojs: ajax
  requirements:
    _permission: 'access content'

ajax_example.dynamic_form_sections:
  path: '/examples/ajax-example/dynamic-form-sections/{nojs}'
  defaults:
    _form: '\Drupal\ajax_example\Form\DynamicFormSections'
    _title: 'Dynamic form sections'
    nojs: 'ajax'
  requirements:
    _permission: 'access content'

ajax_example.wizard:
  path: '/examples/ajax-example/wizard'
  defaults:
    _form: '\Drupal\ajax_example\Form\Wizard'
    _title: 'Wizard with graceful degradation'
  requirements:
    _permission: 'access content'

ajax_example.wizard_no_js:
  path: '/examples/ajax-example/wizard-nojs/{no_js_use}'
  defaults:
    _form: '\Drupal\ajax_example\Form\Wizard'
    _title: 'Wizard with graceful degradation, w/JS turned off'
    no_js_use: TRUE
  requirements:
    _permission: 'access content'

ajax_example.ajax_link_render:
  path: '/examples/ajax-example/ajax-link-renderable'
  defaults:
    _controller: '\Drupal\ajax_example\Controller\AjaxExampleController::renderLinkRenderableArray'
    _title: 'AJAX link from a render array'
  requirements:
    _permission: 'access content'

# This route is for an AJAX callback. It is used by the AJAX system on
# ajax_example.ajax_link_render. It has a {nojs} parameter, which gives us
# a way to know whether the request is an AJAX request or is from some other
# source.
ajax_example.ajax_link_callback:
  path: '/examples/ajax-example/ajax-link-callback/{nojs}'
  defaults:
    _controller: '\Drupal\ajax_example\Controller\AjaxExampleController::ajaxLinkCallback'
  requirements:
    _permission: 'access content'

ajax_example.autocomplete_user:
  path: '/examples/ajax_example/user_autocomplete'
  defaults:
    _form: '\Drupal\ajax_example\Form\EntityAutocomplete'
    _title: 'Autocomplete users with entity_autocomplete'
  requirements:
    _permission: 'access content'

Eleven routes, all in one file, all belonging to one module. Let's work through what makes them tick.

How it works

The route naming convention

ajax_example.simplest:

Every route key follows the pattern <module_name>.<route_id>. The module name prefix namespaces the route so it can never collide with a route from a different module, even if two modules both happen to pick the word "simplest" as an ID. You'll use these full names constantly — to generate URLs with Url::fromRoute('ajax_example.simplest'), and to build links with Link::createFromRoute().

path — the URL pattern

Every path starts with a forward slash. Most are static, like '/examples/ajax-example'. But some contain a dynamic placeholder wrapped in curly braces, like {nojs} in '/examples/ajax-example/dependent-dropdown/{nojs}'. Drupal captures whatever text appears in that URL position and hands it to your controller or form as an argument — you'll see exactly why that matters in a moment.

defaults — controller, form, and title

This section tells Drupal what to actually run when the route matches:

  • _controller — a fully-qualified class and method, in the form '\Namespace\Class::method'. Drupal builds the controller through its service container and calls that method, which returns a render array (or, as you'll see next lesson, an AJAX response) directly.
  • _form — a fully-qualified class name implementing FormInterface (almost always by extending FormBase). Drupal builds and renders the form for you automatically. Every AJAX form demo in this module uses this.
  • _title — the static string shown in the page's title block.

The {nojs} pattern — graceful degradation

ajax_example.dependent_dropdown:
  path: '/examples/ajax-example/dependent-dropdown/{nojs}'
  defaults:
    nojs: ajax

This is one of the cleverest small ideas in Drupal's AJAX system, and worth slowing down for. Here's the sequence:

  • A visitor with JavaScript enabled lands on the page at .../dependent-dropdown — the {nojs} segment is simply omitted from the URL, so the default value 'ajax' applies automatically.
  • Drupal's AJAX JavaScript library rewrites any AJAX callback URLs on the page, replacing the literal word ajax with nojs, whenever it detects the request is happening without JavaScript actually driving it.
  • The form class receives whichever value ended up in the URL as a plain PHP argument. If it's 'ajax', the form returns AJAX commands (a partial DOM update). If it's 'nojs', it falls back to a completely normal full-page form submission.

One route definition, one form class, and both a modern AJAX experience and a fully working non-JavaScript fallback — with zero duplicated code.

requirements — access control

Every route here uses _permission: 'access content', a core permission granted to anonymous and authenticated users by default. You could just as easily restrict a route to logged-in users only with _user_is_logged_in: 'TRUE', or to a specific role, or to a custom permission defined in your own module's .permissions.yml file.

Controller routes vs. form routes

Notice that most routes here use _form, but two — ajax_example.description and ajax_example.ajax_link_render — use _controller instead. That split isn't arbitrary: _form is for anything that's fundamentally a Form API form (with fields, validation, and submission), while _controller is for overview pages and for dedicated AJAX callback endpoints that just need to hand back a render array or an AJAX response. You'll meet both patterns properly over the next two lessons.

A dedicated AJAX callback route

ajax_example.ajax_link_callback:
  path: '/examples/ajax-example/ajax-link-callback/{nojs}'
  defaults:
    _controller: '\Drupal\ajax_example\Controller\AjaxExampleController::ajaxLinkCallback'
  requirements:
    _permission: 'access content'

Look closely and you'll notice this route has no _title. That's deliberate — nobody is meant to browse to this URL directly. It exists purely as a callback endpoint that Drupal's AJAX system calls behind the scenes when a link is clicked on the ajax_example.ajax_link_render page (which we'll visit in the third lesson of this topic). The inline YAML comment above it explains exactly that — leaving a short comment on a route whose purpose isn't obvious from its name alone is good practice you should carry into your own modules.

Sharing one form class across two routes

ajax_example.wizard:
  path: '/examples/ajax-example/wizard'
  defaults:
    _form: '\Drupal\ajax_example\Form\Wizard'

ajax_example.wizard_no_js:
  path: '/examples/ajax-example/wizard-nojs/{no_js_use}'
  defaults:
    _form: '\Drupal\ajax_example\Form\Wizard'
    no_js_use: TRUE

Both of these routes point at the exact same Wizard class, but pass different default parameter values. The second route's no_js_use: TRUE default tells the form to behave as though JavaScript were switched off — a deliberately reachable "what does this look like with JS disabled" test URL, without writing a second form class to get it.

Small inconsistency worth noticing: almost every path here uses hyphens as word separators (ajax-example), except ajax_example.autocomplete_user, whose path uses underscores (/examples/ajax_example/user_autocomplete). It still works — Drupal doesn't care — but hyphens are the conventional, SEO-friendly choice for URLs. Keep your own paths consistent.

See it for yourself

Every one of those routes is a real page. Visit the module's overview page and you'll see them all listed as clickable links.

The AJAX Example module overview page listing every AJAX demo route

That's the whole map for this topic — over the next two lessons we'll click into the "Submit-driven AJAX" and "AJAX link from a render array" links you can see here, and watch exactly how a click on this page turns into a DOM update without a reload.

Quick check: if you wanted a form to work identically whether JavaScript was on or off, which route parameter would you reach for, and what would you set its default value to? (Answer: {nojs}, defaulted to 'ajax'.)

Key takeaways

  • Every Drupal route is declared in <module>.routing.yml with a namespaced key, a path, defaults, and requirements — the same four ingredients whether or not AJAX is involved.
  • Use _form for Form API classes and _controller for custom controller methods; this choice determines how Drupal builds and renders the response.
  • The {nojs} path parameter, defaulted to 'ajax', is Drupal's built-in graceful-degradation pattern — one route and one class can serve both AJAX and non-JavaScript visitors.
  • _permission under requirements is your first line of access control; pick the most restrictive permission that still lets legitimate users through.
  • One form class can power multiple routes by passing different default parameter values — reuse beats duplication.
  • Dedicated AJAX callback routes (no _title, a controller method returning an AjaxResponse) are ordinary routes doing an unusual job — a short YAML comment explaining their purpose is a courtesy to your future self.

Coming up next

Now that you know how every AJAX demo in this module gets a URL, it's time to see what happens after a visitor lands on one and actually clicks something. In the next lesson we'll open SubmitDriven.php and trace, method by method, what happens between a button click and a piece of the page updating itself — no reload, no flicker.