Build a Record Type Router (Apex + Metadata)
This is the step-by-step build for Don't Add a Second Door. If you haven't read that yet, start there, it covers the why. Come back here for the how: the Apex, the metadata, and the Aura component to wire it all together.
Design Overview
Routing policy lives in metadata, not code. Add a guided record type by adding a row, remove it by deactivating one. A missing rule defaults to the standard create page, nothing breaks.
The Aura component reads the object from the page reference, so one component covers any object's New button. This guide builds and tests on Cases, but it's not Case-specific.
flowchart LR
A([New button]) --> B[Aura]
B --> C[Apex router]
C --> D{Metadata}
D -- Guided --> E([Flow])
D -- No rule --> F([Standard])Metadata Model
Create Custom Metadata Type
- Label:
Record Type Config - Plural Label:
Record Type Configs - Object Name:
Record_Type_Config - Description:
Configuration keyed by Object and Record Type. Used to drive routing, defaults, and other per-record-type behaviour. - Visibility:
All Apex code and APIs can use the type, and it's visible in Setup.

Create Metadata Fields
| Label | Field Name (enter this) | Type | Length | Description | Field Manageability |
|---|---|---|---|---|---|
| Object API Name | Object_Api_Name |
Text | 80 | The object this row applies to (for example Case). |
Leave default |
| Record Type Developer Name | Record_Type_Developer_Name |
Text | 80 | The RecordType.DeveloperName this row applies to. |
Leave default |
| Guided Flow API Name | Guided_Flow_Api_Name |
Text | 255 | Flow API name to launch when this route is guided. | Leave default |
| Is Active | Is_Active |
Checkbox | n/a | Include this row in routing only when checked. | Leave default |

We'll come back to populate this once everything below is in place.
Create Apex Class
- Class Name:
RecordTypeNewRouter
public with sharing class RecordTypeNewRouter {
public static final String MODE_STANDARD = 'STANDARD';
public static final String MODE_GUIDED = 'GUIDED';
public class RouteRequest {
@AuraEnabled public String objectApiName { get; set; }
@AuraEnabled public String recordTypeId { get; set; }
@AuraEnabled public String contextRecordId { get; set; }
@AuraEnabled public String contextObjectApiName { get; set; }
}
public class RouteResponse {
@AuraEnabled public String mode { get; set; }
@AuraEnabled public String guidedFlowApiName { get; set; }
@AuraEnabled public String outputRecordTypeId { get; set; }
@AuraEnabled public String contextRecordId { get; set; }
@AuraEnabled public String contextObjectApiName { get; set; }
@AuraEnabled public String defaultFieldValues { get; set; }
}
public class RouteConfig {
public String recordTypeDeveloperName;
public String guidedFlowApiName;
}
public interface RouteConfigProvider {
List<RouteConfig> getActiveConfigs(String objectApiName);
}
public interface RecordTypeResolver {
String getDeveloperName(String objectApiName, Id recordTypeId);
}
private class CmdtRouteConfigProvider implements RouteConfigProvider {
public List<RouteConfig> getActiveConfigs(String objectApiName) {
List<RouteConfig> configs = new List<RouteConfig>();
for (Record_Type_Config__mdt row : [
SELECT Record_Type_Developer_Name__c, Guided_Flow_Api_Name__c
FROM Record_Type_Config__mdt
WHERE Object_Api_Name__c = :objectApiName
AND Is_Active__c = true
]) {
RouteConfig config = new RouteConfig();
config.recordTypeDeveloperName = row.Record_Type_Developer_Name__c;
config.guidedFlowApiName = row.Guided_Flow_Api_Name__c;
configs.add(config);
}
return configs;
}
}
private class SoqlRecordTypeResolver implements RecordTypeResolver {
public String getDeveloperName(String objectApiName, Id recordTypeId) {
if (String.isBlank(objectApiName) || recordTypeId == null) {
return null;
}
List<RecordType> rows = [
SELECT DeveloperName
FROM RecordType
WHERE Id = :recordTypeId
AND SObjectType = :objectApiName
LIMIT 1
];
return rows.isEmpty() ? null : rows[0].DeveloperName;
}
}
@TestVisible private static RouteConfigProvider routeConfigProvider = new CmdtRouteConfigProvider();
@TestVisible private static RecordTypeResolver recordTypeResolver = new SoqlRecordTypeResolver();
@TestVisible
private static void setProviders(RouteConfigProvider configProvider, RecordTypeResolver resolver) {
routeConfigProvider = (configProvider == null) ? new CmdtRouteConfigProvider() : configProvider;
recordTypeResolver = (resolver == null) ? new SoqlRecordTypeResolver() : resolver;
}
@AuraEnabled(cacheable=true)
public static RouteResponse route(RouteRequest request) {
RouteResponse response = new RouteResponse();
response.mode = MODE_STANDARD;
if (request == null || String.isBlank(request.objectApiName)) {
return response;
}
String objectApiName = request.objectApiName.trim();
Id recordTypeId = parseId(request.recordTypeId);
String contextRecordId = String.isBlank(request.contextRecordId) ? null : request.contextRecordId.trim();
String contextObjectApiName = String.isBlank(request.contextObjectApiName) ? null : request.contextObjectApiName.trim();
response.outputRecordTypeId = (recordTypeId == null) ? null : String.valueOf(recordTypeId);
response.contextRecordId = contextRecordId;
response.contextObjectApiName = contextObjectApiName;
String recordTypeDeveloperName = recordTypeResolver.getDeveloperName(objectApiName, recordTypeId);
if (!String.isBlank(recordTypeDeveloperName)) {
Map<String, RouteConfig> byRecordTypeDeveloperName = new Map<String, RouteConfig>();
for (RouteConfig cfg : routeConfigProvider.getActiveConfigs(objectApiName)) {
if (cfg == null || String.isBlank(cfg.recordTypeDeveloperName)) {
continue;
}
byRecordTypeDeveloperName.put(cfg.recordTypeDeveloperName, cfg);
}
RouteConfig match = byRecordTypeDeveloperName.get(recordTypeDeveloperName);
if (match != null && !String.isBlank(match.guidedFlowApiName)) {
response.mode = MODE_GUIDED;
response.guidedFlowApiName = match.guidedFlowApiName.trim();
}
}
response.defaultFieldValues = buildDefaultFieldValues(objectApiName, contextRecordId, contextObjectApiName);
return response;
}
@TestVisible
private static String buildDefaultFieldValues(String objectApiName, String contextRecordId, String contextObjectApiName) {
if (String.isBlank(contextRecordId)) {
return null;
}
List<String> pairs = new List<String>();
// Field prefill is currently Case-specific. Extend this method for
// other objects if needed.
// contextObjectApiName is the reliable check. The startsWith prefix
// is a defensive fallback for when decodeContext fails silently
// because Salesforce changes their internal page reference format.
Boolean isAccountContext = contextObjectApiName == 'Account' || contextRecordId.startsWith('001');
Boolean isContactContext = contextObjectApiName == 'Contact' || contextRecordId.startsWith('003');
if (objectApiName == 'Case' && isAccountContext) {
pairs.add('AccountId=' + contextRecordId);
}
if (objectApiName == 'Case' && isContactContext) {
pairs.add('ContactId=' + contextRecordId);
// Also prefill AccountId from the Contact's parent Account
String accountId = getAccountIdFromContact(contextRecordId);
if (accountId != null) {
pairs.add('AccountId=' + accountId);
}
}
return pairs.isEmpty() ? null : String.join(pairs, ',');
}
private static String getAccountIdFromContact(String contactId) {
try {
List<Contact> contacts = [
SELECT AccountId FROM Contact WHERE Id = :contactId LIMIT 1
];
return (contacts.isEmpty() || contacts[0].AccountId == null)
? null
: String.valueOf(contacts[0].AccountId);
} catch (Exception ex) {
System.debug(LoggingLevel.WARN, 'RecordTypeNewRouter: failed to resolve AccountId from Contact ' + contactId + ': ' + ex.getMessage());
return null;
}
}
private static Id parseId(String rawId) {
if (String.isBlank(rawId)) {
return null;
}
String trimmed = rawId.trim();
try {
return (Id) trimmed;
} catch (Exception ex) {
return null;
}
}
}
Create Apex Test Class
75% coverage to deploy. Don't skip it.
- Class Name:
RecordTypeNewRouterTest
@IsTest
private class RecordTypeNewRouterTest {
private class FakeConfigProvider implements RecordTypeNewRouter.RouteConfigProvider {
private List<RecordTypeNewRouter.RouteConfig> configs;
FakeConfigProvider(List<RecordTypeNewRouter.RouteConfig> configs) {
this.configs = configs;
}
public List<RecordTypeNewRouter.RouteConfig> getActiveConfigs(String objectApiName) {
return configs;
}
}
private class FakeRecordTypeResolver implements RecordTypeNewRouter.RecordTypeResolver {
private String devName;
FakeRecordTypeResolver(String devName) {
this.devName = devName;
}
public String getDeveloperName(String objectApiName, Id recordTypeId) {
return devName;
}
}
private static RecordTypeNewRouter.RouteConfig guidedConfig(String devName, String flowName) {
RecordTypeNewRouter.RouteConfig cfg = new RecordTypeNewRouter.RouteConfig();
cfg.recordTypeDeveloperName = devName;
cfg.guidedFlowApiName = flowName;
return cfg;
}
// --- Routing tests ---
@IsTest
static void routesGuidedWhenAllowlisted() {
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>{
guidedConfig('Technical_Support', 'Case_Create_Technical_Support')
}),
new FakeRecordTypeResolver('Technical_Support')
);
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assertEquals(RecordTypeNewRouter.MODE_GUIDED, res.mode);
System.assertEquals('Case_Create_Technical_Support', res.guidedFlowApiName);
}
@IsTest
static void routesStandardWhenNoMetadataRule() {
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>()),
new FakeRecordTypeResolver('Billing')
);
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assertEquals(RecordTypeNewRouter.MODE_STANDARD, res.mode);
System.assertEquals(null, res.guidedFlowApiName);
}
@IsTest
static void routesStandardWhenRecordTypeNotFound() {
// Covers the path where the resolver returns null (valid ID format
// but no matching RecordType in the org).
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>{
guidedConfig('Technical_Support', 'Case_Create_Technical_Support')
}),
new FakeRecordTypeResolver(null)
);
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assertEquals(RecordTypeNewRouter.MODE_STANDARD, res.mode);
}
@IsTest
static void routesStandardWhenFlowNameBlank() {
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>{
guidedConfig('Refunds', '')
}),
new FakeRecordTypeResolver('Refunds')
);
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assertEquals(RecordTypeNewRouter.MODE_STANDARD, res.mode);
}
@IsTest
static void routesStandardWhenRequestNull() {
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(null);
System.assertEquals(RecordTypeNewRouter.MODE_STANDARD, res.mode);
System.assertEquals(null, res.defaultFieldValues);
}
@IsTest
static void routesStandardWhenObjectApiNameBlank() {
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = '';
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assertEquals(RecordTypeNewRouter.MODE_STANDARD, res.mode);
}
// --- Default field value tests ---
@IsTest
static void prefillsAccountIdFromAccountContext() {
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>()),
new FakeRecordTypeResolver('Billing')
);
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
req.contextRecordId = '001000000000000AAA';
req.contextObjectApiName = 'Account';
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assertEquals('AccountId=001000000000000AAA', res.defaultFieldValues);
}
@IsTest
static void prefillsAccountIdByPrefixFallback() {
// Prefix fallback covers cases where decodeContext fails to extract
// contextObjectApiName but still has the record ID.
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>()),
new FakeRecordTypeResolver('Billing')
);
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
req.contextRecordId = '001000000000000AAA';
req.contextObjectApiName = null;
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assert(res.defaultFieldValues != null && res.defaultFieldValues.contains('AccountId=001000000000000AAA'));
}
// This test uses Test.startTest()/stopTest() because it inserts real
// records and hits real SOQL (getAccountIdFromContact). The other tests
// use fakes and don't need it.
@IsTest
static void prefillsContactIdFromContactContext() {
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>()),
new FakeRecordTypeResolver('Billing')
);
Account testAccount = new Account(Name = 'Test Account');
insert testAccount;
Contact testContact = new Contact(
FirstName = 'Test', LastName = 'Contact', AccountId = testAccount.Id
);
insert testContact;
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
req.contextRecordId = String.valueOf(testContact.Id);
req.contextObjectApiName = 'Contact';
Test.startTest();
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
Test.stopTest();
System.assert(res.defaultFieldValues.contains('ContactId=' + testContact.Id),
'Should prefill ContactId');
System.assert(res.defaultFieldValues.contains('AccountId=' + testAccount.Id),
'Should also prefill AccountId from the Contact parent');
}
@IsTest
static void noDefaultFieldValuesWithoutContext() {
RecordTypeNewRouter.setProviders(
new FakeConfigProvider(new List<RecordTypeNewRouter.RouteConfig>()),
new FakeRecordTypeResolver('Billing')
);
RecordTypeNewRouter.RouteRequest req = new RecordTypeNewRouter.RouteRequest();
req.objectApiName = 'Case';
req.recordTypeId = '012000000000000AAA';
RecordTypeNewRouter.RouteResponse res = RecordTypeNewRouter.route(req);
System.assertEquals(null, res.defaultFieldValues);
}
}
Create Aura Component Bundle
One component covers any object's New button. It reads objectApiName from the page reference, so no object-specific code is ever needed.
- Component Name:
RecordTypeNewOverride
RecordTypeNewOverride.cmp
<aura:component controller="RecordTypeNewRouter" implements="lightning:actionOverride,lightning:hasPageReference">
<aura:attribute name="pageReference" type="Object" />
<aura:attribute name="isRouting" type="Boolean" default="false" />
<aura:attribute name="objectApiName" type="String" default="" />
<aura:attribute name="backgroundContext" type="String" default="" />
<aura:attribute name="navigationLocation" type="String" default="" />
<aura:handler name="init" value="{!this}" action="{!c.start}" />
<aura:handler name="change" value="{!v.pageReference}" action="{!c.start}" />
<lightning:navigation aura:id="navService" />
<lightning:flow aura:id="guidedFlow" onstatuschange="{!c.handleFlowStatus}" />
</aura:component>
RecordTypeNewOverrideController.js
({
start : function(component, event, helper) {
var pageReference = component.get("v.pageReference");
// pageReference may not be populated during init; the change
// handler will re-invoke start once Salesforce provides it.
if (!pageReference || !pageReference.state) {
return;
}
// Guard against init/change double-fire while routing is in flight.
// Do not persist dedupe across launches, or valid later launches
// can be skipped and leave a blank override shell.
if (component.get("v.isRouting")) {
return;
}
component.set("v.isRouting", true);
var state = pageReference.state;
var objectApiName = (pageReference.attributes && pageReference.attributes.objectApiName) || "";
component.set("v.objectApiName", objectApiName);
var recordTypeId = state.recordTypeId || "";
var context = helper.decodeContext(state.inContextOfRef);
var fallbackFilter = state.filterName || "Recent";
var backgroundContext = state.backgroundContext || context.backgroundContext
|| "/lightning/o/" + objectApiName + "/list?filterName=" + encodeURIComponent(fallbackFilter);
component.set("v.backgroundContext", backgroundContext);
component.set("v.navigationLocation", state.navigationLocation || "");
if (!recordTypeId) {
helper.navigateStandard(component, {
outputRecordTypeId: null,
defaultFieldValues: null
});
return;
}
var action = component.get("c.route");
action.setParams({
request: {
objectApiName: objectApiName,
recordTypeId: recordTypeId,
contextRecordId: context.recordId,
contextObjectApiName: context.objectApiName
}
});
action.setCallback(this, function(resp) {
var respState = resp.getState();
if (respState !== "SUCCESS") {
if (respState === "ERROR") {
console.error("RecordTypeNewRouter.route error", resp.getError());
}
helper.navigateStandard(component, {
outputRecordTypeId: recordTypeId,
defaultFieldValues: null
});
return;
}
var route = resp.getReturnValue();
if (route && route.mode === "GUIDED" && route.guidedFlowApiName) {
try {
component.find("guidedFlow").startFlow(route.guidedFlowApiName, [
{ name : "contextRecordId", type : "String", value : route.contextRecordId || "" },
{ name : "contextObjectApiName", type : "String", value : route.contextObjectApiName || "" }
]);
component.set("v.isRouting", false);
} catch (e) {
console.error("Guided flow launch failed; falling back to standard create", e);
helper.navigateStandard(component, {
outputRecordTypeId: (route.outputRecordTypeId || recordTypeId),
defaultFieldValues: route.defaultFieldValues || null
});
}
} else {
route = route || {};
if (!route.outputRecordTypeId) {
route.outputRecordTypeId = recordTypeId;
}
helper.navigateStandard(component, route);
}
});
$A.enqueueAction(action);
},
// backgroundContext holds the originating record or list view URL.
// ERROR status fires when the flow is not found or has no active version.
handleFlowStatus : function(component, event, helper) {
var status = event.getParam("status");
if (status === "ERROR") {
console.error("Guided flow error; falling back to standard create", event.getParam("error"));
helper.navigateStandard(component, {});
return;
}
if (status === "FINISHED" || status === "FINISHED_SCREEN") {
// If the flow handled navigation itself (for example via an LWC
// local action), do not also navigate from the host override.
// Requires flow variable navigateToRecordId to be Available for output.
var outputs = event.getParam("outputVariables") || [];
var navigateToRecordId = "";
outputs.forEach(function(o) {
if (o.name === "navigateToRecordId") {
navigateToRecordId = o.value || "";
}
});
if (navigateToRecordId) {
return;
}
var backgroundContext = component.get("v.backgroundContext");
if (backgroundContext) {
var navEvent = $A.get("e.force:navigateToURL");
navEvent.setParams({ url : backgroundContext });
navEvent.fire();
} else {
var objectApiName = component.get("v.objectApiName");
navEvent = $A.get("e.force:navigateToObjectHome");
navEvent.setParams({ scope : objectApiName });
navEvent.fire();
}
}
}
})
RecordTypeNewOverrideHelper.js
({
// nooverride: "1" tells Salesforce to skip the action override
// (this component) and show the native create page instead.
navigateStandard : function(component, route) {
component.set("v.isRouting", false);
var objectApiName = component.get("v.objectApiName");
var navState = { nooverride : "1" };
var navigationLocation = component.get("v.navigationLocation");
var backgroundContext = component.get("v.backgroundContext");
if (navigationLocation) {
navState.navigationLocation = navigationLocation;
}
if (backgroundContext) {
navState.backgroundContext = backgroundContext;
}
if (route && route.outputRecordTypeId) {
navState.recordTypeId = route.outputRecordTypeId;
}
if (route && route.defaultFieldValues) {
navState.defaultFieldValues = route.defaultFieldValues;
}
component.find("navService").navigate({
type : "standard__objectPage",
attributes : { objectApiName : objectApiName, actionName : "new" },
state : navState
}, true);
},
// Decode the parent record context from inContextOfRef.
// Salesforce encodes the originating page reference as a base64 string
// with URL-safe characters (- instead of +, _ instead of /).
// This is an undocumented internal format; see Caveats section.
decodeContext : function(inContextOfRef) {
var result = { recordId: "", objectApiName: "", backgroundContext: "" };
if (!inContextOfRef) {
return result;
}
try {
var encoded = inContextOfRef;
if (encoded.indexOf(".") !== -1) {
encoded = encoded.substring(encoded.indexOf(".") + 1);
}
encoded = encoded.replace(/-/g, "+").replace(/_/g, "/");
while (encoded.length % 4) {
encoded += "=";
}
var ref = JSON.parse(window.atob(encoded));
if (ref.attributes) {
result.recordId = ref.attributes.recordId || "";
result.objectApiName = ref.attributes.objectApiName || "";
if (ref.attributes.recordId && ref.attributes.objectApiName) {
result.backgroundContext = "/lightning/r/" + ref.attributes.objectApiName + "/" + ref.attributes.recordId + "/view";
} else if (ref.attributes.objectApiName) {
var filterName = (ref.state && ref.state.filterName) ? ref.state.filterName : "Recent";
result.backgroundContext = "/lightning/o/" + ref.attributes.objectApiName + "/list?filterName=" + encodeURIComponent(filterName);
}
}
return result;
} catch (e) {
return result;
}
}
})
Override the New Button
Setup -> Object Manager -> Case -> Buttons, Links, and Actions -> New -> Edit.
- Override With:
Lightning Component - Lightning Component:
c:RecordTypeNewOverride - Skip Record Type Selection Page: unchecked

With no metadata rows in place yet, every record type routes to standard create. Test each one and confirm behavior looks identical to before the override was applied. That's the point. Non-guided record types just work, no configuration needed.
Create a Test Flow
A minimal flow to verify end-to-end routing. The example below creates a Case with optional account context and navigates to the new record. Your guided flow logic will differ.
- API Name:
Case_Create_Test
| API Name | Data Type | Available for Input | Available for Output | Description |
|---|---|---|---|---|
contextRecordId |
Text | checked | unchecked | Parent record ID passed in by the Aura component. |
contextObjectApiName |
Text | checked | unchecked | API name of the context object (for example Account). |
navigateToRecordId |
Text | unchecked | checked | ID of the record to open. Aura reads this from outputVariables to avoid double navigation. |
- Add a Create Records element and create a Case. The exact field mapping will vary by use case.
- Add an Assignment element. Set
navigateToRecordIdto the created Case ID. - Add an Action element and select
flowRecordNavigator(set up in Navigate to a Record from a Screen Flow). MapnavigateToRecordIdto{!navigateToRecordId}.

Add Your Records
Only add rows for guided record types. No row means standard behavior.
[!todo] Add screenshot of the Custom Metadata records list after adding these rows.
| Object | RecordTypeDeveloperName | GuidedFlowApiName | Active |
|---|---|---|---|
| Case | Technical_Support | Case_Create_Technical_Support | true |
| Case | Refunds | Case_Create_Refunds | true |
Notes
The Aura component is Aura because it has to be. Salesforce doesn't support LWC for standard action overrides. If that changes, migrate it.
The Apex route method uses @AuraEnabled(cacheable=true), so routing is fast but metadata changes may not show immediately. Hard-refresh (Ctrl+Shift+R) clears it. Drop cacheable=true if you need immediate consistency.
Adding an action override changes where Salesforce takes you after saving, even on the standard path.
| New from list view | New from related list | |
|---|---|---|
| No override | Opens new record | Stays on parent, green toast |
| This override (standard path) | Opens new record | Opens new record |
| This override (guided path) | TBC | TBC |