StoreConnect Liquid Controllers
A Liquid controller is a theme template whose key matches a registered
controller/action pair. It runs around the platform's own handling of that
request: {% before %} before the action, {% after %} when the response is
about to be sent, {% final %} as the last hook of the request. Controllers are
the storefront-side alternative to Apex for request-time logic, and they can
write to store data, so treat every one as a security-relevant change.
Controllers are entirely opt-in. The base theme ships no controller
templates, so nothing you see on a default store comes from one, and there is no
built-in fallback to inherit behavior from.
Is a controller the right tool?
Work down this list and stop at the first row that fits.
| Need |
Use this instead of a controller |
| Display or compute something for one template |
Plain Liquid in the page, block, or snippet |
| Collect user input and validate it |
{% form %} — see storeconnect-forms |
| Update part of a page without a reload |
{% component %} — see storeconnect-components |
| A brand-new URL path |
pages/not_found routing, or a Salesforce-side route; see storeconnect-apex-integration |
| Privileged administration, bulk data work, credential handling, payment capture, anything needing sharing/FLS enforcement or retries |
Apex or Flow — see storeconnect-apex-integration |
| Reading or writing records for a different customer or store than the current request |
Apex or Flow. A controller must never do this. |
A controller is right when all of these hold: the work must happen on the
server, it must happen during an existing registered request, it only touches the
current store and the current session's own data, and it is cheap enough to run
on every matching request.
Will it run at all?
- Template key is
controllers/<controller>/<action>, for example
controllers/carts/update, controllers/pages/show,
controllers/checkout/steps/terms/update.
- The pair must already be registered. An unregistered key is never invoked
and fails silently — no error, no log entry you will notice.
- Do not derive a key from a URL. The Liquid controller name often differs
from the URL:
/checkout/accept_terms is checkout/steps/terms, a content page
at any slug is pages/show, and the home page is pages/home (a separate key).
- Confirm the exact pair against the
controllers.csv exported with the target
theme, or the published Liquid Controllers reference on
https://support.storeconnect.com/. Neither guessing nor pattern-matching is
acceptable here.
controllers/theme is the global controller and runs on every request to
the store, including component reloads and asset-adjacent routes. Keep it
minimal.
Adding or changing a controller template is a theme change. Use the current
supported publish and cache-refresh workflow described in
storeconnect-sync-deploy, then verify the resolved storefront behavior.
Phases
Two controller templates can run per request: the global controllers/theme and
the route's own controller.
| Phase |
Runs |
Order |
Use it for |
{% before %} |
After platform setup (store, customer, cart resolved), before the platform action |
theme controller first, then page controller |
Gating and redirects, validating input, injecting params/variables the page will read, reading state the action is about to destroy |
{% after %} |
When the platform action is about to send its response |
page controller first, then theme controller |
Persisting extras once the action has succeeded, wrapping or replacing the rendered body |
{% final %} |
Last hook of the request |
page controller first, then theme controller |
Fire-and-forget outbound calls, work that must happen on every branch |
Ordering and short-circuit rules, all verified:
- The first
{% respond %} or {% redirect %} wins; later ones anywhere in
the request are ignored.
{% redirect %} skips everything from that point on: the rest of the same
phase, the platform action, after, and final.
{% respond %} skips all later phases. In before it also cancels the
platform action.
- Both are inert in
final — there is no responder in that phase, so the tag
silently does nothing.
{% variables %} set in before reach the page. Set in after, the page has
already rendered and only the layout sees them.
{% after %} does not tell you whether the platform action succeeded.
original_response is populated only on the render path, so after a redirect
there is nothing to inspect, and the phase fires on both the success and the
failure branch. Some actions bypass after entirely and only reach final.
If a write must happen exactly once regardless of branch, do it in final and
make it idempotent.
Keep every side-effecting tag inside a phase block. The controller template
is evaluated once per phase, so a {% params %}, {% action %}, {% update %},
{% session %}, or {% api %} left at the top level of the file fires on every
pass — up to three times per request. This is the single most common controller
bug in real themes. Top-level {% assign %} and {% capture %} are harmless.
Security rules — non-negotiable
This skill authorizes server-side writes. Every rule below applies before you
write a single tag.
- Validate and normalize every request value before any write. Values read
from
current_request.params are HTML-escaped, which is not validation. Check
type, length, and allowed range, and reject anything unexpected instead of
coercing it.
- Never pass a raw request value into a tag option. Match it against an
expected set, or rebuild it from trusted data, first.
- Require an unambiguous single-match target. Resolve the record you are
about to write by iterating trusted server-side state and confirming exactly
one match. If zero or more than one match, do nothing and surface a message.
- Scope by store, and for customer data by the authenticated customer. Only
act on records reachable from
current_store, current_customer,
current_cart, or the current session. Never widen that scope.
- Never trust a request-supplied SFID or record ID. Treat it as an untrusted
hint to be matched against a trusted current-store or current-customer
collection, never as authorization. Resolve exactly one permitted record
before calling any record-selecting action.
{% update %} requires a read-write Custom Data Mapping for that object and
field, plus the required field access. A field with no mapping, or a read-only
mapping, is silently ignored — no error, no warning. See
storeconnect-salesforce-data for mapping setup and
storeconnect-liquid for cast rules.
- Do not use a controller for privileged administration or payment data. Never
place a literal credential in a template, browser asset, repository file, or
manually constructed authorization value. Where an approved integration uses
the established Store Variable pattern, keep the value server-side in the
documented option and never render or log it. Card data, bank details, and
payment mutation belong in Salesforce with proper enforcement
(
storeconnect-apex-integration).
- Outbound calls go only to an approved endpoint. Never build the URL for
{% api %} from a request value, and never forward request headers, cookies,
raw paths, customer identifiers, record IDs, or payment details to an external
service. Send the minimum an approved integration needs, with consent where
required.
- Never cache output a controller varied per customer, session, or request.
Keep it outside every
{% cache %} block.
Failure modes to design around
Controllers fail quietly. Assume nothing worked until you have verified it.
| Symptom |
Cause |
| Nothing happens at all |
The key does not match the current supported route pair, synchronization is incomplete, or the supported refresh has not completed |
| Params, variables, and a redirect all vanish together |
Any Liquid error anywhere in the controller template discards that controller's params, variables, and responder for that phase. Side effects that already ran (action, update, session, api) are not rolled back, so you get a half-applied controller. A single typo'd variable name is enough. |
| A cart action does nothing |
A wrong option name (for example product_id instead of product_identifier), a missing prerequisite (no logged-in customer, no cart), or an unavailable product. Failed actions log and no-op; they never raise. |
{% update %} writes nothing |
No read-write Custom Data Mapping, or missing field access |
{% redirect %} goes nowhere |
The URL option is to:. path: is silently ignored. |
Action tags inside {% cache %} stop firing |
On a cache hit the block body is never rendered, so the side effects never run. Never wrap action tags in {% cache %}. |
| A variable set on the page request is missing when a component reloads |
A component reload is a separate request. Set it again from controllers/theme or controllers/async/components/load. |
Performance
A controller runs inside the request, so its cost is page latency.
controllers/theme runs on every request. Guard all work behind a narrow
condition before doing anything expensive.
- The template body is evaluated once per phase. A
{% query %} outside a phase
block runs up to three times per request.
{% api %} blocks the render. Synchronous calls in before or after
hold the response open for as long as the HTTP client allows, and the tag sets
no timeout of its own, so an unreachable endpoint can stall a page for tens of
seconds. Never make a synchronous call to an endpoint you do not control the
latency of on a page-render path.
{% final %} is still inside the request cycle, not after the response is
delivered. Only {% api %} is forced async there; a {% query %} or
{% update %} in final still delays the response.
- Cache the result of an external lookup in the page or snippet that consumes
it, never the controller block that fetches it.
Verification after any controller change
- Use the current supported publish and cache-refresh workflow
(
storeconnect-sync-deploy).
- Load the exact URL the registered pair serves and confirm the intended
behavior, including the case where the input is missing or invalid.
- Confirm the phase actually ran with a temporary
{% debug key: value %} probe
using non-sensitive values, and check the platform Console. If nothing appears,
the key does not match a registered pair. Remove the probe afterwards.
- Check the Console for Liquid errors on that template. Any error means the
controller's params, variables, and responder were discarded.
- For a write, read the record back through the storefront and confirm the value
persisted and is correctly escaped where it is displayed.
- Confirm an unauthenticated visitor and a second customer cannot reach or
influence the write.
- Re-check any cached fragment on the affected pages for cross-customer
leakage.
Which reference to read
- Writing any action tag, or unsure of an option name → read
references/action-tags.md. It has the exact
signature and options for
params, variables, redirect, respond,
update, action (with every registered action name), api, session,
header, and debug, plus the tag-option parsing rules that silently break
inline filters.
- Calling an external service from a controller → read the
api section of
references/action-tags.md before writing it. It
runs inside the page render, sets no timeout, and needs explicit status
handling.
- Building one of the common controller shapes (capture an extra form field,
gate a page, inject render data, call an API with failure handling, return
JSON, add a virtual route) → read
references/controller-patterns.md for a
working generic example to start from.
- Choosing between
before, after, and final for a specific job → the
phase-selection rules and the registry excerpt are in
references/controller-patterns.md.
Related skills
storeconnect-liquid for tags, filters, drops, and {% update %} cast rules.
storeconnect-forms for form names and field inventory.
storeconnect-components for async reload and the built-in JSON response.
storeconnect-apex-integration for anything that needs Salesforce-side
enforcement. storeconnect-debug-performance for the Console, {% timer %}, and
{% cache %} strategy. storeconnect-sync-deploy for pushing the template
through the reviewed publish and supported cache-refresh workflow.
1---2name: storeconnect-controllers-23description: Run server-side logic around a StoreConnect page request using Liquid controller templates — the before/after/final phases, the registered controller/action key rule, action tags (params, variables, redirect, respond, update, action, api), form-submission interception, and the safety rules for controller-driven writes. Use when storefront behavior must run on the server without Apex — access gating and redirects, capturing extra form fields, cart automation, injecting data for the render, or calling an external service during a render.4---56# StoreConnect Liquid Controllers78A Liquid controller is a theme template whose key matches a **registered9controller/action pair**. It runs around the platform's own handling of that10request: `{% before %}` before the action, `{% after %}` when the response is11about to be sent, `{% final %}` as the last hook of the request. Controllers are12the storefront-side alternative to Apex for request-time logic, and they can13write to store data, so treat every one as a security-relevant change.1415Controllers are entirely opt-in. The base theme ships **no** controller16templates, so nothing you see on a default store comes from one, and there is no17built-in fallback to inherit behavior from.1819## Is a controller the right tool?2021Work down this list and stop at the first row that fits.2223| Need | Use this instead of a controller |24|---|---|25| Display or compute something for one template | Plain Liquid in the page, block, or snippet |26| Collect user input and validate it | `{% form %}` — see `storeconnect-forms` |27| Update part of a page without a reload | `{% component %}` — see `storeconnect-components` |28| A brand-new URL path | `pages/not_found` routing, or a Salesforce-side route; see `storeconnect-apex-integration` |29| Privileged administration, bulk data work, credential handling, payment capture, anything needing sharing/FLS enforcement or retries | Apex or Flow — see `storeconnect-apex-integration` |30| Reading or writing records for a *different* customer or store than the current request | Apex or Flow. A controller must never do this. |3132A controller is right when **all** of these hold: the work must happen on the33server, it must happen during an existing registered request, it only touches the34current store and the current session's own data, and it is cheap enough to run35on every matching request.3637## Will it run at all?3839- Template key is **`controllers/<controller>/<action>`**, for example40 `controllers/carts/update`, `controllers/pages/show`,41 `controllers/checkout/steps/terms/update`.42- The pair must already be registered. An unregistered key is **never invoked**43 and fails silently — no error, no log entry you will notice.44- **Do not derive a key from a URL.** The Liquid controller name often differs45 from the URL: `/checkout/accept_terms` is `checkout/steps/terms`, a content page46 at any slug is `pages/show`, and the home page is `pages/home` (a separate key).47- Confirm the exact pair against the `controllers.csv` exported with the target48 theme, or the published Liquid Controllers reference on49 `https://support.storeconnect.com/`. Neither guessing nor pattern-matching is50 acceptable here.51- `controllers/theme` is the global controller and runs on **every** request to52 the store, including component reloads and asset-adjacent routes. Keep it53 minimal.5455Adding or changing a controller template is a theme change. Use the current56supported publish and cache-refresh workflow described in57`storeconnect-sync-deploy`, then verify the resolved storefront behavior.5859## Phases6061Two controller templates can run per request: the global `controllers/theme` and62the route's own controller.6364| Phase | Runs | Order | Use it for |65|---|---|---|---|66| `{% before %}` | After platform setup (store, customer, cart resolved), before the platform action | theme controller first, then page controller | Gating and redirects, validating input, injecting params/variables the page will read, reading state the action is about to destroy |67| `{% after %}` | When the platform action is about to send its response | page controller first, then theme controller | Persisting extras once the action has succeeded, wrapping or replacing the rendered body |68| `{% final %}` | Last hook of the request | page controller first, then theme controller | Fire-and-forget outbound calls, work that must happen on every branch |6970Ordering and short-circuit rules, all verified:7172- The **first** `{% respond %}` or `{% redirect %}` wins; later ones anywhere in73 the request are ignored.74- `{% redirect %}` skips everything from that point on: the rest of the same75 phase, the platform action, `after`, and `final`.76- `{% respond %}` skips all **later** phases. In `before` it also cancels the77 platform action.78- Both are **inert in `final`** — there is no responder in that phase, so the tag79 silently does nothing.80- `{% variables %}` set in `before` reach the page. Set in `after`, the page has81 already rendered and only the layout sees them.82- `{% after %}` does **not** tell you whether the platform action succeeded.83 `original_response` is populated only on the render path, so after a redirect84 there is nothing to inspect, and the phase fires on both the success and the85 failure branch. Some actions bypass `after` entirely and only reach `final`.86 If a write must happen exactly once regardless of branch, do it in `final` and87 make it idempotent.8889**Keep every side-effecting tag inside a phase block.** The controller template90is evaluated once per phase, so a `{% params %}`, `{% action %}`, `{% update %}`,91`{% session %}`, or `{% api %}` left at the top level of the file fires on every92pass — up to three times per request. This is the single most common controller93bug in real themes. Top-level `{% assign %}` and `{% capture %}` are harmless.9495## Security rules — non-negotiable9697This skill authorizes server-side writes. Every rule below applies before you98write a single tag.99100- **Validate and normalize every request value before any write.** Values read101 from `current_request.params` are HTML-escaped, which is not validation. Check102 type, length, and allowed range, and reject anything unexpected instead of103 coercing it.104- **Never pass a raw request value into a tag option.** Match it against an105 expected set, or rebuild it from trusted data, first.106- **Require an unambiguous single-match target.** Resolve the record you are107 about to write by iterating trusted server-side state and confirming exactly108 one match. If zero or more than one match, do nothing and surface a message.109- **Scope by store, and for customer data by the authenticated customer.** Only110 act on records reachable from `current_store`, `current_customer`,111 `current_cart`, or the current session. Never widen that scope.112- **Never trust a request-supplied SFID or record ID.** Treat it as an untrusted113 hint to be matched against a trusted current-store or current-customer114 collection, never as authorization. Resolve exactly one permitted record115 before calling any record-selecting action.116- **`{% update %}` requires a read-write Custom Data Mapping** for that object and117 field, plus the required field access. A field with no mapping, or a read-only118 mapping, is silently ignored — no error, no warning. See119 `storeconnect-salesforce-data` for mapping setup and120 `storeconnect-liquid` for cast rules.121- **Do not use a controller for privileged administration or payment data.** Never122 place a literal credential in a template, browser asset, repository file, or123 manually constructed authorization value. Where an approved integration uses124 the established Store Variable pattern, keep the value server-side in the125 documented option and never render or log it. Card data, bank details, and126 payment mutation belong in Salesforce with proper enforcement127 (`storeconnect-apex-integration`).128- **Outbound calls go only to an approved endpoint.** Never build the URL for129 `{% api %}` from a request value, and never forward request headers, cookies,130 raw paths, customer identifiers, record IDs, or payment details to an external131 service. Send the minimum an approved integration needs, with consent where132 required.133- **Never cache output a controller varied per customer, session, or request.**134 Keep it outside every `{% cache %}` block.135136## Failure modes to design around137138Controllers fail quietly. Assume nothing worked until you have verified it.139140| Symptom | Cause |141|---|---|142| Nothing happens at all | The key does not match the current supported route pair, synchronization is incomplete, or the supported refresh has not completed |143| Params, variables, and a redirect all vanish together | **Any** Liquid error anywhere in the controller template discards that controller's params, variables, and responder for that phase. Side effects that already ran (`action`, `update`, `session`, `api`) are **not** rolled back, so you get a half-applied controller. A single typo'd variable name is enough. |144| A cart action does nothing | A wrong option name (for example `product_id` instead of `product_identifier`), a missing prerequisite (no logged-in customer, no cart), or an unavailable product. Failed actions log and no-op; they never raise. |145| `{% update %}` writes nothing | No read-write Custom Data Mapping, or missing field access |146| `{% redirect %}` goes nowhere | The URL option is `to:`. `path:` is silently ignored. |147| Action tags inside `{% cache %}` stop firing | On a cache hit the block body is never rendered, so the side effects never run. Never wrap action tags in `{% cache %}`. |148| A variable set on the page request is missing when a component reloads | A component reload is a separate request. Set it again from `controllers/theme` or `controllers/async/components/load`. |149150## Performance151152A controller runs inside the request, so its cost is page latency.153154- `controllers/theme` runs on every request. Guard all work behind a narrow155 condition before doing anything expensive.156- The template body is evaluated once per phase. A `{% query %}` outside a phase157 block runs up to three times per request.158- **`{% api %}` blocks the render.** Synchronous calls in `before` or `after`159 hold the response open for as long as the HTTP client allows, and the tag sets160 no timeout of its own, so an unreachable endpoint can stall a page for tens of161 seconds. Never make a synchronous call to an endpoint you do not control the162 latency of on a page-render path.163- `{% final %}` is still inside the request cycle, not after the response is164 delivered. Only `{% api %}` is forced async there; a `{% query %}` or165 `{% update %}` in `final` still delays the response.166- Cache the *result* of an external lookup in the page or snippet that consumes167 it, never the controller block that fetches it.168169## Verification after any controller change1701711. Use the current supported publish and cache-refresh workflow172 (`storeconnect-sync-deploy`).1732. Load the exact URL the registered pair serves and confirm the intended174 behavior, including the case where the input is missing or invalid.1753. Confirm the phase actually ran with a temporary `{% debug key: value %}` probe176 using non-sensitive values, and check the platform Console. If nothing appears,177 the key does not match a registered pair. Remove the probe afterwards.1784. Check the Console for Liquid errors on that template. Any error means the179 controller's params, variables, and responder were discarded.1805. For a write, read the record back through the storefront and confirm the value181 persisted and is correctly escaped where it is displayed.1826. Confirm an unauthenticated visitor and a second customer cannot reach or183 influence the write.1847. Re-check any cached fragment on the affected pages for cross-customer185 leakage.186187## Which reference to read188189- **Writing any action tag, or unsure of an option name** → read190 [references/action-tags.md](references/action-tags.md). It has the exact191 signature and options for `params`, `variables`, `redirect`, `respond`,192 `update`, `action` (with every registered action name), `api`, `session`,193 `header`, and `debug`, plus the tag-option parsing rules that silently break194 inline filters.195- **Calling an external service from a controller** → read the `api` section of196 [references/action-tags.md](references/action-tags.md) *before* writing it. It197 runs inside the page render, sets no timeout, and needs explicit status198 handling.199- **Building one of the common controller shapes** (capture an extra form field,200 gate a page, inject render data, call an API with failure handling, return201 JSON, add a virtual route) → read202 [references/controller-patterns.md](references/controller-patterns.md) for a203 working generic example to start from.204- **Choosing between `before`, `after`, and `final` for a specific job** → the205 phase-selection rules and the registry excerpt are in206 [references/controller-patterns.md](references/controller-patterns.md).207208## Related skills209210`storeconnect-liquid` for tags, filters, drops, and `{% update %}` cast rules.211`storeconnect-forms` for form names and field inventory.212`storeconnect-components` for async reload and the built-in JSON response.213`storeconnect-apex-integration` for anything that needs Salesforce-side214enforcement. `storeconnect-debug-performance` for the Console, `{% timer %}`, and215`{% cache %}` strategy. `storeconnect-sync-deploy` for pushing the template216through the reviewed publish and supported cache-refresh workflow.