Trail Map โ€บ Use-Case Catalog โ€บ Custom Classes & Pages
๐Ÿงฉ Extend the Platform ยท For Developers & Admins

Custom Classes & Pages: Build Your Own Screens, Backed by Governed Code

When a standard record page, related list, or Workflow Rule action just isn't enough, Custom Classes and Custom Pages let you write your own small scripts and screens โ€” safely boxed in and limited, so they can never affect anything outside your own company's data. Heads up: unlike most other trails, this one involves writing some real code, so it's best suited to admins who are comfortable with that.

4
Tabs
~50 min
Time to Complete
80 pts
Available
1 Badge
Platform Builder

"We need one screen that searches across objects โ€” not just one record's related list."

RS
Ravi Shah ยท Implementation Partner

"Our support desk wants to type an account name and instantly see every contact under it โ€” before they've even opened the account record. A related list only shows contacts once you're already on that one account's page. On the old platform we'd write an Apex controller and a Lightning page for this. Can we do that here without standing up a whole dev environment?"

Most day-to-day work fits the standard patterns you already know โ€” record detail pages, related lists, list views, Reports. But some needs genuinely don't: a search screen that spans multiple objects, a dashboard-style widget with custom logic, or a lookup a rep needs before a record even exists.

SmartLite CRM has a safe answer for exactly this gap: Custom Classes (small scripts that run behind the scenes, safely boxed in) and Custom Pages (standalone screens you build yourself that call a Custom Class to fetch data). The same Custom Class can also be attached to a Workflow Rule or Journey Builder step, so the script you write once can run automatically whenever a record is saved, not just when a user opens a screen.

๐Ÿ’ก
What you'll be able to do
Build a real cross-object search screen โ€” reachable from your app's nav like any other tab โ€” backed by a governed script you write yourself, with zero platform code changes.

Two building blocks, one governed sandbox

Custom Classes and Custom Pages are separate pieces that plug together โ€” you'll usually build a Class first, then a Page (or a Workflow Rule action) that calls it.

Custom ClassA saved, safely boxed-in script that reads and writes records through the same permission-checked system the rest of the CRM uses โ€” it can never see another company's data, and it can never touch files, the internet, or run system commands. Written under Setup โ†’ Developer Console โ†’ Saved Scripts, admin-only.
PARAMSWhen a Custom Page calls a Custom Class, whatever it passes in (like a typed search term) arrives inside the script as PARAMS. A Class run from a Workflow Rule or batch job has no PARAMS โ€” those are two different ways of triggering the same script.
Custom PageA standalone screen you build yourself, written under Setup โ†’ Custom Pages. It runs inside a locked-down, safely boxed-in area with no direct access to the internet โ€” its only way to fetch or change data is by calling a Custom Class.
SmartLiteComponent.callClass(...)The one bridge a Custom Page has back to the server. Call it with a Class's API name and a params object; you get back whatever the Class's script returns, as plain data you can render.
"Run Custom Class" actionA Workflow Rule or Journey Builder step type that runs a Custom Class automatically after a record is created or updated โ€” no PARAMS, since there's no user typing anything in.
Where a Custom Class can runGets PARAMS?Triggered by
Custom Page (on demand)YesA user action inside the page, e.g. clicking Search
Custom Component, on a record's sidebar or main body (on demand)YesThe component's own script calling SmartLiteComponent.callClass(...) โ€” see the Component Library trail
Workflow Rule / Journey Builder โ€” "Run Custom Class"NoRecord created/updated (after save)
Batch mode (from Developer Console)NoManually started, processes records in chunks
โœ…
Same script, different doors
Write a Class defensively (check whether PARAMS exists before reading from it) and you can reuse the exact same script from a Custom Page and a Workflow Rule, instead of writing it twice.
โš ๏ธ
Everything has a usage limit
Every script run โ€” whether from a Page, a Workflow Rule, or a batch job โ€” counts against your company's daily script-run limit, and each run has to finish within 15 seconds. A Custom Page that calls a Class on every keystroke will use up that limit fast; only call it on an explicit action like a Search button.

๐ŸŒฑ Beginner: the three verbs every script uses

Before any of the bigger builds below, every Custom Class leans on the same three data operations โ€” all of them permission-checked, org-isolated, and impossible to bypass with raw SQL (there isn't any).

Create a recordCRM.insert('lead', [first_name: 'Priya', last_name: 'Nair', email: 'priya@x.com']) โ€” returns the new record's ID.
Update a recordCRM.update(leadId, [status: 'Contacted']) โ€” only the fields you pass are changed; everything else is left alone.
Read records backCRM.query('lead', 'status', 'New') โ€” filtered by one field, or CRM.getRecord('lead', leadId) for a single record by ID.
๐Ÿ’ก
Try it now
Setup โ†’ Developer Console โ†’ Saved Scripts โ†’ New Script. Paste return CRM.insert('lead', [first_name: 'Test', last_name: 'Lead', email: 'test@example.com']), save, and click Run. You'll get back the new Lead's ID immediately โ€” that's the whole loop.

1 Intermediate: build the account โ†’ contacts search screen

You'll build exactly the screen Ravi asked for in the About tab: type part of an account name, see every contact under every matching account.

1. Write the Custom ClassSetup โ†’ Developer Console โ†’ Saved Scripts tab โ†’ New Script. API name findContactsByAccountName. In the source editor, write a script that reads PARAMS.q, finds matching Accounts with CRM.query('account'), then finds each one's Contacts with CRM.query('contact', 'account_id', accountHexId), and returns the combined list โ€” the script's last expression becomes its result.
2. Save and mark it ActiveAn inactive script can't be invoked from a Page or a Workflow Rule โ€” double-check the Active toggle before moving on.
3. Build the Custom PageSetup โ†’ Custom Pages โ†’ New Page. API name accountContactSearch. Add a text input, a Search button, and a results list; in the button's click handler, call SmartLiteComponent.callClass('findContactsByAccountName', { q: /* the input's value */ }) and render whatever comes back.
4. Give it a home in the navSetup โ†’ Tab Manager โ†’ New Tab. Choose Standard, and set the route to /custom-pages/accountContactSearch. Assign the new tab to whichever app your team lives in.
5. Try it as a regular userOpen the tab, type part of an account name, click Search. Unlike Setup pages, running the page itself doesn't require an admin role โ€” any teammate with the tab in their nav can use it.
6. (Stretch) Wire it into automation tooOpen a Workflow Rule on Account, add a "Run Custom Class" action, and point it at findContactsByAccountName. It'll now also fire automatically whenever a matching Account is saved โ€” remember, in that context the script gets no PARAMS, so guard for that if you're reusing the same script.

2 Advanced: process thousands of records with Batch Mode

Some jobs are too big for one 15-second run โ€” like backfilling a field across every Lead your company has ever created. Batch Mode splits the work into chunks and runs them automatically, one after another, until every record has been through the script.

1. Write a batch-shaped scriptNew Script, check Batch Mode. Set Object API Name to lead and Chunk Size to 200 (the default). Instead of PARAMS, your script now works against BATCH โ€” a list of the current chunk's records: BATCH.each { record -> if (!record.lead_source) { CRM.update(record.hex_id, [lead_source: 'Unknown']) } }
2. Save, mark Active, click Run as BatchThis returns immediately โ€” it just starts the job. The actual chunks are processed in the background every 30 seconds, not all at once.
3. Watch it under Recent Batch JobsStatus moves PENDING โ†’ RUNNING โ†’ COMPLETED, with a running count of records processed and chunks completed. A failed chunk retries automatically (with a short wait that doubles each time) before giving up โ€” you don't have to babysit it.
โœ…
Fresh budget every chunk
The 150-row/15-second limits reset for every chunk, not once for the whole job โ€” a batch job over 10,000 records just means 50 chunks of 200, each with its own full budget, not one shared one.

3 Pro: run a batch script automatically, every day

Turn the manual "Run as Batch" click from the previous step into a standing job that fires on its own โ€” no one has to remember to start it.

1. Open your batch-mode scriptIt must already have Batch Mode checked โ€” Run Daily is a property of a batch script, not a separate feature.
2. Check "Run Daily"A new field appears: Time (UTC) โ€” pick the hour of day (e.g. 02:00 UTC) you want it to start.
3. Save โ€” that's itFrom now on, every day at your chosen hour, a new batch run starts by itself. The script list shows a "Daily HH:00 UTC" badge on any script scheduled this way, so you can see at a glance what's running automatically.
โš ๏ธ
It never stacks runs
If yesterday's scheduled run is still going when today's trigger time arrives, today's run is quietly skipped rather than starting a second one on top of it โ€” you'll never get two overlapping jobs fighting over the same records.

4 Syntax reference: declaring a variable, building a class, calling it

Every Custom Class is Groovy โ€” close to Java, forgiving about types. Here's the three things you'll write over and over.

Declaring a variableAlways start with def, not a specific type โ€” def name = 'Priya', def count = 0, def rows = []. This isn't just simpler: the safety sandbox only allows calling methods on a variable when its type isn't pinned down, so def is what makes some of the patterns below (like your own model classes) actually compile.
Building a classclass ClassName { def methodName(params) { ...your logic...; return someValue } } โ€” the class's own name in the code is its API name; there's no separate name field to fill in anywhere. Whatever the method's last line evaluates to (or an explicit return) becomes the result. Platform โ€” the toolbox with insert/update/query/getRecord/log/httpGet etc. โ€” is simply available inside any method, with nothing to import or declare.
Calling a classFour doors, one script โ€” see the table above for the full picture. From code, a class can also call its own other methods directly (helper()), or call a different, already-saved class by name: Platform.callClass('OtherClassName', 'someMethod', [key: 'value']) โ€” same governed budget as the caller, just one extra level of nesting (capped at 5 deep, so a class can't accidentally call itself in an infinite loop).
๐Ÿ’ก
Minimal working example
class LeadService {
    def getSummary(params) {
        def lead = Platform.getRecord('lead', params.leadId)
        return [ name: lead.first_name + ' ' + lead.last_name, status: lead.status ]
    }
}
Save this under Setup โ†’ Classes. From a Custom Page: SmartLiteComponent.callClass('LeadService', 'getSummary', { leadId: 'a1b2c3' }).

5 Typed model classes โ€” a real class with getters and setters

A method doesn't have to return a plain list of loose key/value pairs. It can build and return a list of a proper model class you define yourself, with real fields and real get/set methods โ€” closer to how you'd shape data in a typed language.

Declare a second class in the same scriptYour main callable class goes first (its name is what gets saved as the API name); a plain model class can follow it in the same source file.
Instantiate it with defdef b = new Breed() โ€” not Breed b = new Breed(). As above, this is what lets the safety sandbox allow it.
Computed properties are freeA getter doesn't have to just return a stored field โ€” it can compute something on the fly, like joining a list into a display string. The Custom Page template (below) can bind to a computed getter exactly the same way it binds to a plain one.
class DogBreeds {
    def getBreeds(params) {
        def list = []
        def b = new Breed()
        b.setBreedName('Husky')
        b.setSubBreeds(['sub-breed'])
        list.add(b)
        return [ breeds: list ]
    }
}

class Breed {
    private String breedName
    private List subBreeds
    String getBreedName() { return breedName }
    void setBreedName(String v) { this.breedName = v }
    List getSubBreeds() { return subBreeds }
    void setSubBreeds(List v) { this.subBreeds = v }
    String getSubBreedsText() { return subBreeds ? subBreeds.join(', ') : '-' }  // computed
}

6 Call a third-party web service (Named Credentials)

A Class can reach out to an external API โ€” a shipping-rate lookup, a Slack post, a firmographic enrichment service โ€” but it never sees a raw URL or a secret. Both live in a Named Credential, resolved automatically at call time.

1. Register the credentialSetup โ†’ Named Credentials โ†’ New. Give it an API name (e.g. shippingApi), the base URL, and how to authenticate (None / API Key Header / Bearer Token / Basic).
2. Call it from a Classdef res = Platform.httpGet('shippingApi', '/rates?zip=94105') โ€” also httpPost/httpPut/httpDelete, all returning [status, body, headers]. The auth header is injected for you.
3. It's still governedOnly https://, only public addresses (no reaching your own internal network), no more than 10 outbound calls per script run, and a strict response-size/time cap โ€” the same governed-sandbox philosophy as everything else in this trail.

7 Render results with an LWC-style template

A Custom Page doesn't need hand-written DOM code to show a list or a value โ€” its built-in template engine uses the same directive names as a Salesforce Lightning Web Component template, so if you already know LWC, you already know this.

<div id="result">Loading...</div>
<template id="tpl">
  <table>
    <template for:each={breeds} for:item="breed">
      <tr key={breed.breedName}><td>{breed.breedName}</td><td>{breed.subBreedsText}</td></tr>
    </template>
  </table>
  <template if:false={breeds}><p>No breeds found.</p></template>
</template>
<script>
  SmartLiteComponent.renderClass('DogBreeds', 'getBreeds', {}, '#result', '#tpl');
</script>
โœ…
No innerHTML, ever
The template's markup is read natively by the browser and bound with real DOM calls โ€” nothing here is built by concatenating an HTML string, so data can never accidentally be parsed as markup.

Day-to-day: regular users (non-technical)

Open the tab like any otherOnce an admin has built and published it, a Custom Page shows up in the nav exactly like Reports or Dashboard โ€” no coding knowledge needed to use it.
Use it as designedType a search term, click the button, read the results โ€” the underlying script is invisible to you.

50 things you can build

A reference gallery, not a to-do list โ€” skim for the pattern closest to your own need and adapt it. Grouped by the five building blocks this trail covers.

๐Ÿ—‚๏ธ Batch Classes โ€” process thousands of records unattended

1. Backfill a missing fieldDefault every existing Lead's blank lead_source to "Unknown" in one nightly run.
2. Nightly currency conversionRecompute every multi-currency Opportunity's home-currency amount as rates change.
3. Mass re-scoreRe-run updated Lead Scoring logic across every open Lead after the rules change.
4. Archive stale ContactsFlag or deactivate Contacts with no Activity in 2+ years.
5. Recalculate rollups after an importRecompute a Rollup Summary field across every parent Account once a bulk import finishes.
6. Daily closed-won digestBatch class calls a web-service class to email/Slack a summary of that day's wins.
7. Nightly duplicate sweepFlag likely-duplicate Accounts for manual review each night.
8. Nightly price syncRefresh every Product's price from an external price list (batch + web-service callout together).
9. Auto-expire stale QuotesMove Quotes untouched 30+ days in Draft to Expired automatically.
10. Bulk-tag a campaign's LeadsApply a follow-up flag to every Lead sourced from one campaign in a single run.

๐ŸŒ Web Service Callouts โ€” reach a third-party API through a Named Credential

11. Firmographic enrichmentLook up a company's size/industry by domain the moment a Lead is created.
12. Address validationVerify/geocode an Account's billing address on save.
13. Live shipping ratesPull real-time carrier rates into a Quote before it's sent.
14. Slack alert on a big winPost to Slack the instant a deal over $50K closes.
15. SMS confirmationText the customer through a messaging provider when their Case is resolved.
16. Credit-risk checkScreen a prospect against a credit-risk API before a large discount is approved.
17. Marketing platform syncPush a new Contact onto an external mailing list the moment they're created.
18. Live exchange ratesPull current FX rates for multi-currency Opportunity math.
19. Email deliverability checkVerify an email address before a Lead is allowed to move to "Qualified".
20. Mirror a Case to an external toolOpen a matching ticket in another support system for high-priority Cases.

๐Ÿ–ฅ๏ธ Custom Screens โ€” a standalone page your team reaches from the nav

21. Cross-object searchType an Account name, see every Contact under every match โ€” this trail's own flagship example.
22. New-rep onboarding checklistTrack a new hire's setup progress across several objects in one screen.
23. Territory coverage viewOpen Leads grouped by region, outside the standard Kanban.
24. Renewal radarEvery Contract expiring in the next 30 days, in one list.
25. Discount approval calculatorPreview whether a proposed discount will need sign-off, before submitting the Quote.
26. "My Day" screenOverdue Tasks, hot Leads, and Cases awaiting a response, combined in one view.
27. Partner lead-submission formCustom validation on a partner-facing screen before a Lead is created.
28. Live stock lookupProduct availability pulled from a web-service callout in real time.
29. Executive summary screenPipeline, win-rate, and case-backlog numbers combined on one page.
30. "Nearest Account" finderFor field reps โ€” sorted by distance from an entered zip code.

๐Ÿงฌ Typed Model Classes โ€” a class with real getters and setters

31. DealHealthScoreA computed getRiskLevel() derived from several underlying fields.
32. ShippingQuoteWraps a carrier API response into clean carrier/price/eta properties.
33. LeadSummaryCombines Lead fields with its most recent Activity into one object.
34. AccountRollupA computed getTotalOpenPipeline() across an Account's Opportunities.
35. ContractStatusTurns raw dates into a friendly getDaysUntilExpiry() / getIsExpiringSoon().
36. RepScorecardQuota, closed-won total, and a computed getAttainmentPct() together.
37. CaseEscalationA computed getIsOverdue() derived from SLA fields.
38. PriceBookLineFormats a raw number into a display-ready getFormattedPrice().
39. CampaignPerformanceLeads-generated and cost, plus a computed getCostPerLead().
40. TerritoryCoveragePairs a region with a computed getOpenLeadCount().

๐Ÿ”— Automation & Class-Calling โ€” one class calling another

41. Validate-then-notify chainA Workflow's "Run Custom Class" action calls a class that itself calls two others: validation, then notification.
42. Scheduled finance syncA daily batch class calls a web-service-backed class to sync yesterday's closed deals to finance.
43. Component button chainA Custom Component's button runs a class that validates, then calls a second class to post a Slack alert.
44. Volume-aware assignmentA Case-creation Workflow calls a class that checks current case volume before auto-assigning.
45. Personalized discount stepA Journey Builder step calls a class that computes a personalized discount before the quote email sends.
46. Shared enrichment classA batch class and an on-demand Custom Page both call the same "enrichment" class โ€” write it once.
47. Three-class trigger chainOne Opportunity Workflow โ†’ a Scoring class โ†’ a Notification class โ†’ a Webhook class.
48. Reusable alert classOne "SendSlackAlert" class called from five different Workflow Rules across different objects.
49. Self-recursing helperA class walks Account โ†’ its Opportunities โ†’ each one's line items, calling its own helper method at each level.
50. Search-then-enrich screenA Custom Page calls one class to search, then a second class per result row to enrich it via a web service โ€” three patterns above, composed in one screen.

Day-to-day: admins maintaining a script

Update the Class when logic needs to changeEdit the saved script under Developer Console โ†’ Saved Scripts โ€” every Page or Workflow Rule calling it picks up the change immediately.
Check on a daily-scheduled batch jobThe scripts list shows a "Daily HH:00 UTC" badge on anything auto-running; open Recent Batch Jobs to see the most recent run's status and how many records it processed.
A scheduled run didn't seem to fireCheck whether the previous run was still PENDING/RUNNING at the scheduled hour โ€” a still-running prior job is skipped on purpose rather than stacked, so it'll simply pick up at tomorrow's scheduled time instead.
Watch the usage limitIf a page feels slow or hits its limit, check whether it's calling the Class too often (like on every keystroke) instead of on an explicit action.
๐ŸŽฏ
What to try next
Look at the Developer Console trail to get comfortable with the query and script tools this trail relies on.

Test what you learned

1. Where does a Custom Page get the data it displays?
It makes direct network calls to the CRM's API from inside the page
It calls a Custom Class via SmartLiteComponent.callClass(...) and renders whatever comes back
It reads directly from the database
2. A Custom Class is attached to a Workflow Rule's "Run Custom Class" action. What does PARAMS contain when it runs?
Whatever the user last typed into a Custom Page
Nothing โ€” PARAMS is only populated when a Custom Page calls the script on demand
The full record that triggered the rule
3. Who can author (create/edit) Custom Classes and Custom Pages?
Any user with the tab in their nav
Admins only
Only the org's original registering user
4. You've built a Custom Page that calls a Custom Class on every keystroke in a search box. What's the risk?
None โ€” script runs are unlimited
You'll burn through your company's daily script-run limit quickly
The page will stop rendering after 15 seconds
5. How does an end user reach a Custom Page you've built?
It automatically appears on every record's detail page
Via a Tab (Standard type, routed to /custom-pages/{apiName}) assigned to an app
It can only be opened from the Developer Console
6. What does CRM.update(leadId, [status: 'Contacted']) do to the other fields on that Lead?
Clears them, since you didn't include them in the call
Leaves them untouched โ€” only the fields you pass are changed
Requires you to pass every field on the record every time
7. In a Batch Mode script, what replaces PARAMS as the bound variable?
RECORD โ€” the single record being processed
BATCH โ€” a list of the current chunk's records
Nothing โ€” batch scripts still use PARAMS
8. You enable "Run Daily" at 02:00 UTC on a batch script, but yesterday's run is still RUNNING when 02:00 UTC arrives today. What happens?
A second job starts anyway, running alongside the first
Today's scheduled trigger is skipped โ€” it won't stack a second run on top of an active one
The still-running job is cancelled to make room for the new one
9. You write Breed b = new Breed() (a specific type) instead of def b = new Breed(), then call b.setBreedName(...). What happens?
Runs exactly the same either way โ€” def vs a specific type makes no difference
It fails to compile โ€” the safety sandbox only allows a method call like this when the variable's type isn't pinned down
It compiles but silently does nothing at runtime
10. A Class needs to call a shipping-rate API. What does it call to reach it?
A raw URL and API key it stores in the script itself
Platform.httpGet('credentialApiName', '/path'), referencing a Named Credential by API name
It can't โ€” outbound calls aren't supported