Designer reviewing a single-column campaign page with one call-to-action on a widescreen monitor

A landing page is a campaign. One audience, one promise, one button. A portal is the everyday front door: featured thread, new posts, a map of the rooms. If you point Index page route at a splash that only says “Join,” you have not built a landing page. You have hidden the forum from every returning member.

This article is the splash. Ads, Discord pins, a Steam event, a whitelist season, a paid-upgrade week. The URL is something you send. It is not /. Stock XenForo 2.3 already has the pieces: a Page node, template syntax inside that page, View node permissions, notices when the job is smaller than a page, and <xf:widget> if you need one live proof the board is alive.

If you arrived from an old shop URL that sold an “Ultimate Landing Page,” stay. You do not need a product to print a headline and a register button. You need a node you can unpublish when the campaign ends.

Landing page vs portal vs notice

Write the job in one sentence before you open Forums → Nodes.

Job URL you want Tool
Everyday first screen / Portal article. Index route + three widgets
Campaign you will turn off /pages/join/ or /pages/season-3/ This article. Page node. Index route left alone
One line on every page for a week Any page, dismissible Communication → Notices
Application, not a speech /forums/apply/ Whitelist queue

If the sentence is “strangers from this ad should register, then post in Introductions,” you want a landing page. If the sentence is “members should see what moved,” you want the portal. If the sentence is “we are down for an hour,” you want a notice.

Do not combine them. A page node that also tries to be the forum list is a portal with a worse name.

Create the page node

Official path: Forums → Nodes → Add node, type Pages. Official docs: you “can define the arbitrary HTML you wish to use to display in the page.” That HTML is not a dead string. The template engine compiles it. You can output {$variableName}, run <xf:tagname …>, and call {{ functionName() }}. That is how a page becomes a landing page instead of a museum plaque.

A working campaign page, in the order you actually click:

  1. Parent. Put it under a public category, or at the tree root, so a guest who fails the parent View is not locked out by inheritance. Official permission rule: if the parent is inaccessible, the child is inaccessible, local Yes does not save you. Same rule as every other node on this site.
  2. Title. The campaign name, or “Join [community].” Not “Landing v4.”
  3. URL portion. join, season-3, whitelist. You will send /pages/join/. Make it a word a human can read off a stream overlay.
  4. Display in the node list. Off. A campaign page is not a room. Hiding it from the tree does not hide the URL. Official materials and the portal article agree: display-in-list is not a permission.
  5. HTML. One headline, one paragraph, one button. Template syntax. See the next section.
  6. Permissions. Click Permissions next to the node. Default inheritance is fine if guests may see it. If this page is only for people you will send the link to and you want it invisible to random members, use Private node and grant View node = Yes to Unregistered / unconfirmed (and Registered, if members may reopen the campaign). Official docs: Private node clears the baseline; you then add Yes. Other permissions inherit from the group.

Test three sessions before you buy an ad: guest, a throwaway Registered account, you. The usual bug is a parent category that guests cannot view, or a Private node you forgot to grant to Unregistered.

The page is allowed to be small

Here is a complete landing page. Paste it into the page HTML. Change the words. Do not add a slider.

<div class="block">
  <div class="block-container">
    <div class="block-body block-row">
      <h1>Play this season. Apply once.</h1>
      <p>
        We are a small XenForo board for one FiveM city. Read the rules,
        then create an account. Applications open after you confirm your email.
      </p>
      <xf:if is="!$xf.visitor.user_id">
        <a href="{{ link('register') }}" class="button button--cta">
          Create an account
        </a>
      <xf:else />
        <a href="{{ link('forums', {'node_name': 'apply'}) }}" class="button button--cta">
          Open the application
        </a>
      </xf:if>
    </div>
  </div>
</div>

Official template language is doing three jobs:

  • {{ link('register') }} is the stock registration route. Official checklist in the notebook also names account/upgrades when the CTA is a paid group, not a new account.
  • <xf:if is="!$xf.visitor.user_id"> swaps the button for people who already exist. A landing page that tells a logged-in moderator to “Create an account” is an ad, not a page.
  • The button classes are the style’s buttons. You stay inside the theme. You do not invent a third palette in the page HTML.

Need proof the board is not empty? Official docs let you drop a widget on any template that compiles tags:

<xf:widget key="landing_new_posts" />

Create that instance under Appearance → Widgets, key landing_new_posts, definition New posts, tight limit, public rooms only, no forum-list position. Call it by key. The widgets article is the machinery. One proof widget is enough. A Members online block on a campaign page is a vanity number; under guest page cache it is also stale.

Do not put the campaign essay on this page. Link the rules or the handbook page. A landing page that scrolls past the first screen has become a portal with worse navigation.

Do not steal /

Setup → Options → Basic board information → Index page route is how you make a page the board root. Official developer examples use portal/ as the specialized layout. The portal article already walked that option.

For a campaign, leave the index alone.

  • Returning members bookmark /. If / is a splash, they bounce.
  • Forum-list widgets vanish from the first screen when the index is no longer forums/. You already know this.
  • The logo still goes to the index route. A campaign that hijacks the logo hijacks every internal click home.

Send /pages/join/. Put that URL on the ad. If you hate /pages/, official materials list Setup → Route filters as the ACP category that rewrites routes. The notebook we queried does not document the filter form. The portal article’s working pattern is a filter that turns pages/home/ into home/ without stealing forums/ or threads/. Same idea: pages/join/join/. Test it. Do not create a filter named join that collides with a user named Join.

Home page URL (the tab, not the logo) can point at the campaign for a week if you really want the tab to match the ad. Turn it back. Official docs also mention a home_page_url code event for add-ons that rebuild the home link with $router->buildLink('canonical:portal'). That is developer territory. You do not need it for a page node.

Permissions as the campaign gate

Three honest gates.

Everyone may see it. Default inheritance. Guests hit the CTA. This is the ad page.

Only guests may see it. Private node. View node = Yes for Unregistered / unconfirmed. Registered gets No or is left unset so they 404 or get the no-permission page. Useful when the page is an ad creative you do not want sitting in search for members. Remember: a member who opens the Discord link while logged in will hit that wall. Either tell them, or show them the “you already have an account” branch instead of Private-nodeing them out.

Only a qualified group may see it. Private node. View node = Yes for Season3 or Subscriber. Official docs: promotions and trophies evaluate criteria (has_avatar, message counts, “registered for over a year,” reaction score, likes on a single message) and add or remove a secondary group. Gate the page on that group. Do not change anyone’s primary group. The permissions article already explained why.

Parent View still wins. A private landing page under a private staff category is a page nobody from the ad can open. Put campaign pages where guests can stand.

Official registration detail that matters after the click: a new account is not Valid until they use the confirmation mail. Any other user state uses the Unregistered / unconfirmed permission set. Your “Open the application” button must not require a group you only grant after they confirm, unless you said so on the page. CAPTCHA on the register form is a template tag (<xf:captcha>, force="true") in official docs; login CAPTCHA after four failures lives at Setup → Options → User options → Login limit method. A post-register redirect to this landing page is not in official docs. Do not promise one. Link the next step in the page body and in the first staff PM.

Notices: when you do not need a page

Communication → Notices. Official criteria system: user rules (avatar, message count) and page rules (where they are, plus time). A notice that fires when someone hits five likes on one post is the documented example.

Use a notice when the campaign is a strip, not a destination: “Season 3 applications close Friday” on every page, dismissible. Do not use a notice as a landing page. You cannot send a notice URL to an ad network. You cannot permission it like a node. You cannot put a full HTML layout in a notice and still call it a notice.

If the only job is “tell people who already use the board,” ship a notice. If the job is “catch people who do not have an account,” ship a page.

Style the page without cloning PAGE_CONTAINER

The page HTML should use the style’s blocks and buttons. That is how the landing page survives a theme update.

If you need a wider hero or a tighter measure, do it in the child style, scoped to this page. Official Less uses @xf-stylePropertyName. Official 2.3 already unbundles page-specific CSS from the global sheet — the performance article is the reason you should not dump campaign CSS into a global extra.less. Prefer <xf:css src="landing_join.less" /> at the top of the page HTML so the sheet loads here only.

Official warning, again: PAGE_CONTAINER, OFFLINE_CONTAINER, and OAUTH_CONTAINER take core updates. Customizing them is how you collect Appearance → Outdated templates and a broken header. A landing page is not permission to fork the container. Check outdated templates after every upgrade. Merge. Do not skip this because “it is only HTML.”

Need a PHP number the template cannot see? Official <xf:callback> can call a read-only method. Allowed prefixes include get, fetch, render, is, has, count. Example from the docs: <xf:callback class="Vendor\Addon\ClassName" method="getStats" params="['sidebar']"></xf:callback>. A page-node “PHP callback” field is not in the official notebook. Do not go hunting for one. If you need a controller, official path is Development → Routes (prefix, section context, controller class) — that is an add-on, and it is how products like a historic “landing page” add-on justified themselves. You still do not need it for a headline and a button.

What to put in the ad, what to put on the page

The ad (Discord, Google, a stream overlay) gets one URL and one sentence. The page gets the sentence again, the next step, and nothing that will be wrong next month.

  • Do not paste current player counts into the HTML. They rot. Use a widget or omit them.
  • Do not paste a full rulebook. Link it.
  • Do not embed a Discord widget that loads a third-party script on the first paint if you care about LCP. A text invite plus the register button is enough.
  • Do not feature a private thread. Stock widgets honour View; an empty slot looks like a broken page.
  • Do not run the campaign as the index “just for launch week.” Launch week is when returning members decide whether the new URL is still their forum.

When the campaign ends: unpublish the page (or revoke Unregistered View), turn the notice off, revert Home page URL if you touched it. Leave the node in the tree so the URL can 301 later if you ever replace it. Do not delete a URL you bought ads against.

Two campaigns, same node type

Same ACP path. Different CTA. Copy one, do not invent a third.

Season open (games / RP). Ad on Discord: “Apply for Season 3 → yoursite.com/pages/join/”. Page: who the city is, that you must confirm email, that the application is a thread. Logged-out button is register. Logged-in button is the apply forum. Do not put the application form on the landing page. The landing page is the door. The thread type is the form.

Paid upgrade week. Page: what the extra group gets (one private forum, one badge, no ads — only what is true). Button is {{ link('account/upgrades') }}. Guest branch still sends them through register first. Gate any “members-only” preview with Private node + the Subscriber group, not with CSS display:none. Official promotions can add that group later; they cannot hide HTML you already sent to the browser.

A third campaign you should not build as a landing page: “here is everything we ever published.” That is a portal, or a news index. A landing page that lists twelve rooms has already lost the one button.

Mistakes that look like design

What you did What happens Fix
Index route = pages/join/ for “launch week” Returning members lose the forum list; logo goes to the splash Leave the index. Send the page URL
Page under a staff category Guests fail parent View Parent guests can see
Private node, forgot Unregistered The ad 404s View node = Yes for Unregistered / unconfirmed
CSS display:none on the paid block Guests still receive the HTML Private node, or an <xf:if> on $xf.visitor
Discord iframe + video hero LCP dies on the phone Text invite, one button
Page HTML is 80 lines of custom CSS Next style upgrade is a merge Child style + <xf:css>
No logged-in branch Moderators see “Create an account” <xf:if is="!$xf.visitor.user_id">
Deleted the node after the ad ended The bought URL 404s Revoke View; keep the node

Write the CTA as a verb plus an object: “Create an account,” “Open the application,” “See upgrades.” “Learn more” is how a landing page becomes a second homepage.

Checklist: stock landing page that is not the index

  • One sentence: who this is for, what the button does
  • Forums → Nodes → Add node → Pages
  • URL portion you will actually print (join, not page-14)
  • Display in the node list: off
  • Parent node guests can view
  • HTML: headline, paragraph, {{ link('register') }} or account/upgrades or the apply forum
  • Logged-in branch so members are not told to register
  • Optional: one <xf:widget key="…" /> as proof of life, no forum-list position
  • Permissions: public, guest-only, or a promotion group — Private node + View node = Yes
  • Index page route unchanged
  • Optional route filter pages/join/join/ that does not steal forums/
  • Three sessions: guest, new Registered, admin
  • After upgrade: Appearance → Outdated templates
  • End date on a staff calendar

When this is the wrong article

If you need a magazine homepage, go back to the portal. If you need a queue with prefixes and a secondary group, that is the whitelist. If you need a newsroom with editions, wait for the announcements article — a landing page is not a ticker.

A modern XenForo landing page is a page node you can send, permission, and turn off. One CTA. Index left alone. The add-on store can keep the rest.