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-controllers3description: 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<!-- Generated from shared/skills. Do not edit this copy. -->78# StoreConnect Liquid Controllers910A Liquid controller is a theme template whose key matches a **registered11controller/action pair**. It runs around the platform's own handling of that12request: `{% before %}` before the action, `{% after %}` when the response is13about to be sent, `{% final %}` as the last hook of the request. Controllers are14the storefront-side alternative to Apex for request-time logic, and they can15write to store data, so treat every one as a security-relevant change.1617Controllers are entirely opt-in. The base theme ships **no** controller18templates, so nothing you see on a default store comes from one, and there is no19built-in fallback to inherit behavior from.2021## Is a controller the right tool?2223Work down this list and stop at the first row that fits.2425| Need | Use this instead of a controller |26|---|---|27| Display or compute something for one template | Plain Liquid in the page, block, or snippet |28| Collect user input and validate it | `{% form %}` — see `storeconnect-forms` |29| Update part of a page without a reload | `{% component %}` — see `storeconnect-components` |30| A brand-new URL path | `pages/not_found` routing, or a Salesforce-side route; see `storeconnect-apex-integration` |31| Privileged administration, bulk data work, credential handling, payment capture, anything needing sharing/FLS enforcement or retries | Apex or Flow — see `storeconnect-apex-integration` |32| Reading or writing records for a *different* customer or store than the current request | Apex or Flow. A controller must never do this. |3334A controller is right when **all** of these hold: the work must happen on the35server, it must happen during an existing registered request, it only touches the36current store and the current session's own data, and it is cheap enough to run37on every matching request.3839## Will it run at all?4041- Template key is **`controllers/<controller>/<action>`**, for example42 `controllers/carts/update`, `controllers/pages/show`,43 `controllers/checkout/steps/terms/update`.44- The pair must already be registered. An unregistered key is **never invoked**45 and fails silently — no error, no log entry you will notice.46- **Do not derive a key from a URL.** The Liquid controller name often differs47 from the URL: `/checkout/accept_terms` is `checkout/steps/terms`, a content page48 at any slug is `pages/show`, and the home page is `pages/home` (a separate key).49- Confirm the exact pair against the `controllers.csv` exported with the target50 theme, or the published Liquid Controllers reference on51 `https://support.storeconnect.com/`. Neither guessing nor pattern-matching is52 acceptable here.53- `controllers/theme` is the global controller and runs on **every** request to54 the store, including component reloads and asset-adjacent routes. Keep it55 minimal.5657Adding or changing a controller template is a theme change. Use the current58supported publish and cache-refresh workflow described in59`storeconnect-sync-deploy`, then verify the resolved storefront behavior.6061## Phases6263Two controller templates can run per request: the global `controllers/theme` and64the route's own controller.6566| Phase | Runs | Order | Use it for |67|---|---|---|---|68| `{% 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 |69| `{% 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 |70| `{% final %}` | Last hook of the request | page controller first, then theme controller | Fire-and-forget outbound calls, work that must happen on every branch |7172Ordering and short-circuit rules, all verified:7374- The **first** `{% respond %}` or `{% redirect %}` wins; later ones anywhere in75 the request are ignored.76- `{% redirect %}` skips everything from that point on: the rest of the same77 phase, the platform action, `after`, and `final`.78- `{% respond %}` skips all **later** phases. In `before` it also cancels the79 platform action.80- Both are **inert in `final`** — there is no responder in that phase, so the tag81 silently does nothing.82- `{% variables %}` set in `before` reach the page. Set in `after`, the page has83 already rendered and only the layout sees them.84- `{% after %}` does **not** tell you whether the platform action succeeded.85 `original_response` is populated only on the render path, so after a redirect86 there is nothing to inspect, and the phase fires on both the success and the87 failure branch. Some actions bypass `after` entirely and only reach `final`.88 If a write must happen exactly once regardless of branch, do it in `final` and89 make it idempotent.9091**Keep every side-effecting tag inside a phase block.** The controller template92is evaluated once per phase, so a `{% params %}`, `{% action %}`, `{% update %}`,93`{% session %}`, or `{% api %}` left at the top level of the file fires on every94pass — up to three times per request. This is the single most common controller95bug in real themes. Top-level `{% assign %}` and `{% capture %}` are harmless.9697## Security rules — non-negotiable9899This skill authorizes server-side writes. Every rule below applies before you100write a single tag.101102- **Validate and normalize every request value before any write.** Values read103 from `current_request.params` are HTML-escaped, which is not validation. Check104 type, length, and allowed range, and reject anything unexpected instead of105 coercing it.106- **Never pass a raw request value into a tag option.** Match it against an107 expected set, or rebuild it from trusted data, first.108- **Require an unambiguous single-match target.** Resolve the record you are109 about to write by iterating trusted server-side state and confirming exactly110 one match. If zero or more than one match, do nothing and surface a message.111- **Scope by store, and for customer data by the authenticated customer.** Only112 act on records reachable from `current_store`, `current_customer`,113 `current_cart`, or the current session. Never widen that scope.114- **Never trust a request-supplied SFID or record ID.** Treat it as an untrusted115 hint to be matched against a trusted current-store or current-customer116 collection, never as authorization. Resolve exactly one permitted record117 before calling any record-selecting action.118- **`{% update %}` requires a read-write Custom Data Mapping** for that object and119 field, plus the required field access. A field with no mapping, or a read-only120 mapping, is silently ignored — no error, no warning. See121 `storeconnect-salesforce-data` for mapping setup and122 `storeconnect-liquid` for cast rules.123- **Do not use a controller for privileged administration or payment data.** Never124 place a literal credential in a template, browser asset, repository file, or125 manually constructed authorization value. Where an approved integration uses126 the established Store Variable pattern, keep the value server-side in the127 documented option and never render or log it. Card data, bank details, and128 payment mutation belong in Salesforce with proper enforcement129 (`storeconnect-apex-integration`).130- **Outbound calls go only to an approved endpoint.** Never build the URL for131 `{% api %}` from a request value, and never forward request headers, cookies,132 raw paths, customer identifiers, record IDs, or payment details to an external133 service. Send the minimum an approved integration needs, with consent where134 required.135- **Never cache output a controller varied per customer, session, or request.**136 Keep it outside every `{% cache %}` block.137138## Failure modes to design around139140Controllers fail quietly. Assume nothing worked until you have verified it.141142| Symptom | Cause |143|---|---|144| Nothing happens at all | The key does not match the current supported route pair, synchronization is incomplete, or the supported refresh has not completed |145| 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. |146| 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. |147| `{% update %}` writes nothing | No read-write Custom Data Mapping, or missing field access |148| `{% redirect %}` goes nowhere | The URL option is `to:`. `path:` is silently ignored. |149| 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 %}`. |150| 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`. |151152## Performance153154A controller runs inside the request, so its cost is page latency.155156- `controllers/theme` runs on every request. Guard all work behind a narrow157 condition before doing anything expensive.158- The template body is evaluated once per phase. A `{% query %}` outside a phase159 block runs up to three times per request.160- **`{% api %}` blocks the render.** Synchronous calls in `before` or `after`161 hold the response open for as long as the HTTP client allows, and the tag sets162 no timeout of its own, so an unreachable endpoint can stall a page for tens of163 seconds. Never make a synchronous call to an endpoint you do not control the164 latency of on a page-render path.165- `{% final %}` is still inside the request cycle, not after the response is166 delivered. Only `{% api %}` is forced async there; a `{% query %}` or167 `{% update %}` in `final` still delays the response.168- Cache the *result* of an external lookup in the page or snippet that consumes169 it, never the controller block that fetches it.170171## Verification after any controller change1721731. Use the current supported publish and cache-refresh workflow174 (`storeconnect-sync-deploy`).1752. Load the exact URL the registered pair serves and confirm the intended176 behavior, including the case where the input is missing or invalid.1773. Confirm the phase actually ran with a temporary `{% debug key: value %}` probe178 using non-sensitive values, and check the platform Console. If nothing appears,179 the key does not match a registered pair. Remove the probe afterwards.1804. Check the Console for Liquid errors on that template. Any error means the181 controller's params, variables, and responder were discarded.1825. For a write, read the record back through the storefront and confirm the value183 persisted and is correctly escaped where it is displayed.1846. Confirm an unauthenticated visitor and a second customer cannot reach or185 influence the write.1867. Re-check any cached fragment on the affected pages for cross-customer187 leakage.188189## Which reference to read190191- **Writing any action tag, or unsure of an option name** → read192 [references/action-tags.md](references/action-tags.md). It has the exact193 signature and options for `params`, `variables`, `redirect`, `respond`,194 `update`, `action` (with every registered action name), `api`, `session`,195 `header`, and `debug`, plus the tag-option parsing rules that silently break196 inline filters.197- **Calling an external service from a controller** → read the `api` section of198 [references/action-tags.md](references/action-tags.md) *before* writing it. It199 runs inside the page render, sets no timeout, and needs explicit status200 handling.201- **Building one of the common controller shapes** (capture an extra form field,202 gate a page, inject render data, call an API with failure handling, return203 JSON, add a virtual route) → read204 [references/controller-patterns.md](references/controller-patterns.md) for a205 working generic example to start from.206- **Choosing between `before`, `after`, and `final` for a specific job** → the207 phase-selection rules and the registry excerpt are in208 [references/controller-patterns.md](references/controller-patterns.md).209210## Related skills211212`storeconnect-liquid` for tags, filters, drops, and `{% update %}` cast rules.213`storeconnect-forms` for form names and field inventory.214`storeconnect-components` for async reload and the built-in JSON response.215`storeconnect-apex-integration` for anything that needs Salesforce-side216enforcement. `storeconnect-debug-performance` for the Console, `{% timer %}`, and217`{% cache %}` strategy. `storeconnect-sync-deploy` for pushing the template218through the reviewed publish and supported cache-refresh workflow.