52 KiB
//[doc-seo]
{
"Description": "Add the Low-Code designer and a companion React runtime to an existing MVC, Razor Pages, Blazor, or Angular ABP application."
}
Use Low-Code from a Non-React Application
Keep the existing solution, Admin Console, Low-Code backend, and companion React packages aligned with the same ABP version. When an example contains a version placeholder, replace it with the version used by the existing solution.
The Low-Code backend is independent of the application's main UI framework. The current Low-Code runtime renderer, however, is React. An existing MVC, Razor Pages, Blazor, or Angular application can therefore keep its current UI and open generated Low-Code pages in a companion React application.
Use this topology:
| URL | Owner | Purpose |
|---|---|---|
/ |
Existing MVC, Razor Pages, Blazor, or Angular UI | The application's existing user interface |
/admin-console/lowcode-designer |
Admin Console | Design entities, pages, forms, permissions, and actions |
/lowcode/ |
Companion React application | Render generated Low-Code pages at runtime |
/api/* |
Existing backend | Serve both the existing UI and Low-Code runtime |
The backend setup is the same as described in Add Low-Code to an Existing Solution. This page focuses on the UI-specific work that is required when the existing application is not React.
How the Application Changes
This integration adds two React-based experiences next to the existing UI. They run from the same backend, but they do not adopt the existing MVC, Razor Pages, Blazor, or Angular layout.
Admin Console and the Designer
Admin Console is a separate administration application under /admin-console. It has its own layout, navigation, theme, frontend assets, and OIDC client. It does not replace the existing application's administration pages when RedirectRootToAdminConsole is false.
Admin Console discovers the installed modules that expose an Admin Console UI. Consequently, it can display administration interfaces for other supported modules in addition to Low-Code. The Low-Code Designer appears as one of these module interfaces and opens inside Admin Console at /admin-console/lowcode-designer.
This has a visible side effect: users may see two administration experiences with different designs. For example, a module may remain accessible from the existing application's Administration menu while its Admin Console interface is also available under /admin-console. Decide which entry points to expose to each role; hiding a duplicate menu entry does not change its authorization requirements.
Adding Admin Console also adds its routes, static frontend assets, module discovery, configuration endpoints, permissions, and authentication client to the existing host. The host startup and deployment therefore include an additional frontend application even when users continue to start at /.
Low-Code Runtime Pages
Generated Low-Code pages are rendered by a second React application under /lowcode. Opening the Dynamic menu item performs a full-page navigation out of the existing UI. The runtime has its own layout and navigation, so it does not visually inherit the existing application's shell.
The companion runtime uses the same backend APIs, users, roles, tenants, and generated permissions, but it has a dedicated OIDC client and callback URLs. Its build output becomes part of the backend deployment. A scoped SPA fallback is required for deep links, and the /lowcode path must be reserved so it does not conflict with an existing route.
The existing root UI remains unchanged as long as Admin Console root redirection is disabled and the React fallback is limited to /lowcode/*.
Important Boundaries
Volo.Abp.LowCodeprovides the runtime backend.Volo.Abp.LowCode.Designer.*provides the designer application services and HTTP APIs.Volo.Abp.AdminConsoleprovides the browser shell that hosts the designer.@volo/abp-react-lowcodeprovides the runtime renderer.- Angular, MVC, Razor Pages, and Blazor do not currently render Low-Code page descriptors themselves.
Adding only the backend packages does not make the designer UI appear. A non-React host may also be missing Volo.Abp.AdminConsole, so check and add it explicitly.
Step 1: Add the Shared Low-Code Backend
Import with ABP Studio
Use ABP Studio's Import Module action, select Volo.Abp.LowCode, keep Install this module checked, and accept Studio's suggested package-to-layer mapping.
Then complete the manual backend work from Add Low-Code to an Existing Solution:
- create and embed
_Dynamic - add the Low-Code initializer
- initialize the runtime from the host and DbMigrator
- configure the design-time DbContext factory
- implement
IDbContextWithDynamicEntities - call
ConfigureDynamicEntities()andConfigureLowCode() - add the default
DynamicEntityrepository - map Low-Code custom endpoints
- inspect Studio's migration, create one only when the final model still has pending changes, and apply it
The DbContext configuration order is significant and is the same for MVC, Angular, and Blazor solutions. Keep this common backend step outside the UI-specific conditions:
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ConfigureDynamicEntities();
base.OnModelCreating(builder);
builder.ConfigureLowCode();
}
ConfigureDynamicEntities() must run before the base model conventions, while ConfigureLowCode() stays after the base call. Also run the Low-Code initializer before design-time DbContext creation. Otherwise, generated migrations can be incomplete or the runtime and design-time EF Core models can diverge.
Studio may create Added_DynamicEntities_LowCode and insert both configuration calls. Inspect the generated result and move ConfigureDynamicEntities() before the base call when Studio places both calls after it. After completing all backend wiring, run dotnet ef migrations has-pending-model-changes against the EF Core project with DbMigrator as the startup project. Keep Studio's migration when no changes remain; do not add a duplicate migration.
ABP Studio is the supported path in this guide. The remaining steps assume Studio has already added the Low-Code runtime and designer packages to the appropriate layers.
Step 2: Add Admin Console for the Designer
Skip this step only when the startup host already references Volo.Abp.AdminConsole and depends on AbpAdminConsoleModule.
Add the package to the runnable host:
abp add-package Volo.Abp.AdminConsole `
--project "src\Acme.NonReactLowCode.Web\Acme.NonReactLowCode.Web.csproj" `
--version <AbpVersion>
Add the module dependency:
using Volo.Abp.AdminConsole;
[DependsOn(typeof(AbpAdminConsoleModule))]
public class NonReactLowCodeWebModule : AbpModule
{
}
Configure Admin Console in the startup host. Keep RedirectRootToAdminConsole disabled so the existing UI remains at /:
{
"AdminConsole": {
"IsEnabled": true,
"RedirectRootToAdminConsole": false,
"Authority": "https://localhost:44323",
"ClientId": "NonReactLowCode_AdminConsole",
"Scope": "openid profile email offline_access NonReactLowCode",
"LocalizationLanguages": ["en", "tr"],
"VisibleModules": ["lowCodeDesigner"],
"ThemeOverrideCssPath": null,
"InitialTheme": "system",
"CustomizationPermissionName": null
}
}
Create a dedicated public OpenIddict client. Do not reuse the existing MVC, Angular, Blazor, Swagger, or runtime client.
{
"OpenIddict": {
"Applications": {
"NonReactLowCode_AdminConsole": {
"ClientId": "NonReactLowCode_AdminConsole",
"RootUrl": "https://localhost:44323/admin-console"
}
}
}
}
Seed the client with these values:
| Setting | Value |
|---|---|
| Application type | Web |
| Client type | Public |
| Grant types | AuthorizationCode, RefreshToken, LinkLogin, Impersonation |
| Redirect URI | https://localhost:44323/admin-console/ |
| Silent renew URI | https://localhost:44323/admin-console/silent-renew.html |
| Post-logout URI | https://localhost:44323/admin-console/ |
| API scope | The existing backend scope, such as NonReactLowCode |
Fail startup or data seeding when the configured root URL is empty. Do not silently fall back to a development URL.
Run the DbMigrator again so the client and designer permission definitions are seeded. The administrator role needs:
AbpLowCodeDesigner.DefaultAbpLowCodeDesigner.EditAbpLowCodeDesigner.ScriptTestwhen script testing is allowed
These are designer permissions. They are not a replacement for the permissions assigned to generated runtime pages and endpoints.
Limit Admin Console to Low-Code
AbpAdminConsoleOptions does not have a built-in module allowlist. However, the Admin Console React application calls GET /admin-console/api/modules before registering its module routes and sidebar entries. Replace AdminConsoleModuleDiscoveryController with an application controller to control that response without removing backend services.
The VisibleModules value in the earlier configuration example is an application-specific setting consumed by this replacement controller; it is not a built-in Admin Console option. Create LowCodeAdminConsoleModuleDiscoveryController.cs in the startup host:
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Volo.Abp.AdminConsole.Controllers;
using Volo.Abp.AspNetCore.Controllers;
using Volo.Abp.AspNetCore.Mvc;
namespace Acme.NonReactLowCode.Web.Controllers;
[ReplaceControllers(typeof(AdminConsoleModuleDiscoveryController))]
[Route("admin-console/api")]
public class LowCodeAdminConsoleModuleDiscoveryController : AbpController
{
private static readonly string[] KnownModuleKeys =
[
"identity",
"saas",
"auditLogging",
"gdpr",
"openIddict",
"aiManagement",
"lowCodeDesigner",
"settingManagement",
"textTemplateManagement"
];
private readonly HashSet<string> _visibleModules;
public LowCodeAdminConsoleModuleDiscoveryController(IConfiguration configuration)
{
var configuredModules = configuration
.GetSection("AdminConsole:VisibleModules")
.Get<string[]>();
if (configuredModules is null || configuredModules.Length == 0)
{
throw new InvalidOperationException(
"AdminConsole:VisibleModules must contain at least one module key."
);
}
var unknownModules = configuredModules
.Except(KnownModuleKeys, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (unknownModules.Length > 0)
{
throw new InvalidOperationException(
$"Unknown Admin Console module keys: {string.Join(", ", unknownModules)}"
);
}
_visibleModules = configuredModules.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
[HttpGet("modules")]
public IActionResult GetAvailableModules()
{
return Ok(KnownModuleKeys.ToDictionary(
key => key,
key => _visibleModules.Contains(key)
));
}
}
ReplaceControllers removes the original controller from the MVC application model, so the replacement can keep the same route without causing an ambiguous endpoint. With only lowCodeDesigner enabled, Admin Console registers the Low-Code Designer route and omits the Identity, SaaS, Audit Logging, GDPR, OpenIddict, AI Management, Setting Management, and Text Template Management routes. Opening an omitted route directly produces the Admin Console 404 page.
This is global UI composition, not authorization. The omitted modules, application services, HTTP APIs, permissions, and existing MVC, Blazor, or Angular management pages remain installed and operational. Keep permission checks in place and grant designer users only the permissions they require:
- Create a dedicated role such as
LowCodeDesigner. - Grant
AbpLowCodeDesigner.DefaultandAbpLowCodeDesigner.Editto that role. - Grant
AbpLowCodeDesigner.ScriptTestonly when those users may execute designer script tests. - Do not grant unrelated administration permissions unless the same users need them in the existing UI.
Permissions are shared by the existing UI and Admin Console, and grants are additive. The controller allowlist avoids changing those shared grants merely to shape Admin Console. A user still needs the required Low-Code permission even when lowCodeDesigner is returned as true.
The Admin Console home page and authenticated account pages are core routes and remain available. Check whether the installed Admin Console uses static management cards and Quick Navigation links that do not consume the module discovery response. If it does, hidden module links on that page lead to 404; link users directly to /admin-console/lowcode-designer, keep RedirectRootToAdminConsole disabled, and treat the Admin Console root as a known limitation. Removing or redesigning that static home content requires a custom Admin Console frontend. Keep CustomizationPermissionName unset or null to avoid registering the customization route.
The module keys are an Admin Console frontend contract and can change when the package is upgraded. Compare KnownModuleKeys with the upgraded AdminConsoleModuleDiscoveryController and React route configuration during every ABP upgrade. The explicit validation above makes a missing or unknown allowlist fail the discovery request instead of silently exposing additional modules.
Step 3: Create a Companion React Runtime
The safest source for the companion application is a temporary solution generated at exactly the same ABP version. In ABP Studio, create a modern React application with Low-Code enabled and use the same tenancy, authentication, theme, and database-provider choices as the existing solution.
Before adapting or copying the frontend, establish whether the generated seed is clean on its own:
cd react
yarn install --frozen-lockfile
yarn lint
yarn test:run
yarn build
Use the scripts and package manager declared by the generated application when their names differ. If the untouched seed fails, record and resolve that template baseline separately; otherwise later failures can be misattributed to the /lowcode integration. Do not replace its lockfile or switch package managers merely to make the commands pass.
Testing Library packages have peer dependencies. If the test runner or TypeScript cannot resolve screen, waitFor, or fireEvent, inspect package.json and the lockfile. Add a compatible @testing-library/dom version only when it is missing; do not add a duplicate version when the generated application already declares one.
Copy the generated react/ directory into the existing solution, for example as lowcode-react/. Keep the generated ABP authentication, Axios, localization, configureLowCode, createDynamicRoutes, and useMenuItems integration.
Do not copy the temporary backend. The companion React application must use the existing application's backend and database.
Rename and Localize the Companion Application
The copied frontend still contains the temporary solution's identity. Update it before building the application that will be deployed:
- Change the package name in
package.jsonwithout discarding the generated lockfile. - Replace the HTML
<title>inindex.html. - Update
application.name,apis.default.rootNamespace, client IDs, scopes, and URLs indynamic-env.jsonand the development environment source. - Replace the temporary application's
::AppNameand resource-qualified keys insrc/locales/*.json. - Add
Menu:LowCodeDesignerto the localization resource actually loaded by the companion runtime. Adding it only to the MVC, Angular, or Blazor resource does not update a copied React localization snapshot.
Search the copied frontend for the temporary solution and client names. Review every match instead of doing a blind replacement because generated test fixtures can intentionally assert a client ID:
rg "TemporaryProjectName|TemporaryProjectName_App" lowcode-react
After the build, verify the browser tab title, application name, and menu labels. A raw value such as Menu:LowCodeDesigner means the key is absent from the active React localization resource.
Configure the /lowcode Base Path
Set both the bundler and router base paths. Setting only one causes broken assets or broken client-side routes.
In lowcode-react/vite.config.ts:
export default defineConfig(() => ({
base: '/lowcode/',
build: {
outDir: '../src/Acme.NonReactLowCode.Web/wwwroot/lowcode',
emptyOutDir: true,
},
optimizeDeps: {
exclude: ['@volo/abp-react-lowcode'],
},
plugins: [copyDynamicEnv(), react()],
}))
In the TanStack router configuration:
export const router = createRouter({
routeTree,
basepath: '/lowcode',
})
Load dynamic-env.json relative to the Vite base path:
const urlsToTry = [
`${import.meta.env.BASE_URL}dynamic-env.json`,
'/getEnvConfig',
]
The silent renew URI must also include the base path:
const silentRedirectUri =
`${window.location.origin}${import.meta.env.BASE_URL}silent-renew.html`
Configure the Runtime URLs
Use the same backend origin, but create a separate runtime client:
{
"production": "true",
"application": {
"baseUrl": "https://localhost:44323/lowcode/",
"returnUrl": "https://localhost:44323/",
"name": "NonReactLowCode",
"logoUrl": ""
},
"oAuthConfig": {
"issuer": "https://localhost:44323/",
"redirectUri": "https://localhost:44323/lowcode/",
"requireHttps": "true",
"clientId": "NonReactLowCode_LowCodeUi",
"responseType": "code",
"scope": "offline_access NonReactLowCode",
"strictDiscoveryDocumentValidation": false,
"skipIssuerCheck": false
},
"apis": {
"default": {
"url": "https://localhost:44323",
"rootNamespace": "Acme.NonReactLowCode"
}
}
}
The example uses explicit development URLs for clarity. Replace them with deployment-specific configuration. Do not ship requireHttps=false or skipIssuerCheck=true as a production workaround.
Add the Runtime OpenIddict Client
Add this explicit entry to the DbMigrator configuration:
{
"NonReactLowCode_LowCodeUi": {
"ClientId": "NonReactLowCode_LowCodeUi",
"RootUrl": "https://localhost:44323/lowcode"
}
}
Seed it as a public web client with authorization code, refresh token, LinkLogin, and Impersonation grants. Use these exact callback shapes:
https://localhost:44323/lowcode/
https://localhost:44323/lowcode/silent-renew.html
Use https://localhost:44323/lowcode/ as the post-logout URI.
Serve the React Build from the Existing Host
Build the companion application with its existing package manager:
cd lowcode-react
yarn install --frozen-lockfile
yarn lint
yarn test:run
yarn build
Map a scoped SPA fallback in the backend host. Do not cache the fallback HTML because it contains the current hashed JavaScript and CSS asset names:
app.UseConfiguredEndpoints(endpoints =>
{
endpoints.UseLowCodeCustomEndpoints();
endpoints.MapFallbackToFile(
"/lowcode/{*path:nonfile}",
"lowcode/index.html",
new StaticFileOptions
{
OnPrepareResponse = context =>
{
context.Context.Response.Headers.CacheControl =
"no-store, no-cache, must-revalidate";
context.Context.Response.Headers.Pragma = "no-cache";
context.Context.Response.Headers.Expires = "0";
}
}
);
});
Do not use a global fallback. A global fallback can swallow MVC, Razor Pages, Admin Console, and API routes. Hashed assets can remain cacheable; the no-cache rule applies only to fallback index.html responses. Otherwise, a deployment can leave browsers requesting asset hashes that no longer exist and /lowcode/ can become a blank page.
Configure ABP Antiforgery for Mutations
The Low-Code React source template configures ABP's antiforgery cookie and header names, but treat this as a mandatory verification checkpoint because distributed template artifacts can differ. Inspect the companion runtime's shared Axios instance, keep the values when present, and add them when absent. Do not create a second Axios instance just for this setting.
export const api = axios.create({
baseURL: '',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
},
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'RequestVerificationToken',
})
The application-configuration endpoint creates the XSRF-TOKEN cookie. Axios then sends its value as RequestVerificationToken for same-origin mutation requests. This is required when /lowcode/ shares an origin and authentication cookie with the MVC host. Without it, reads can work while create, update, delete, import, and other mutation requests fail with 400; the MVC host may then redirect the XHR to /Error?httpStatusCode=400, making the network trace look like a missing API.
Do not fix this by disabling AbpAntiForgeryOptions.AutoValidate. Disabling validation weakens every affected browser endpoint in the host. If the runtime is deployed on another origin, validate its bearer-token, CORS, cookie, and antiforgery behavior separately; a browser cannot read an XSRF cookie from an unrelated origin.
Also audit redirects and account links copied from a root-hosted React seed. Absolute client paths such as window.location.href = '/403' and '/account/login' leave the /lowcode base path. Use import.meta.env.BASE_URL, the TanStack router, or a validated absolute application URL instead. This applies to forbidden redirects, password-reset completion, login, logout, and any custom navigation added later.
Protect Runtime Menus and Routes
Do not fetch or render dynamic menu items for an anonymous user:
const { isAuthenticated } = useAuth()
const { data: dynamicMenuItems } = useMenuItems({
enabled: isAuthenticated,
})
The returned tree is already filtered by the signed-in user's generated page permissions. Empty groups are removed by the backend. Protect direct dynamic routes separately so hiding the menu is never the authorization boundary:
const dynamicEntityRoute = createDynamicRoutes(rootRoute, {
beforeLoad: authGuard,
})
Preserve the requested deep link before starting OIDC. Use window.location.href, not TanStack's location.href, because the router value does not include the /lowcode base path:
const PENDING_RETURN_URL_KEY = 'abp.oidc.pending-return-url'
export async function authGuard({ location }: GuardContext) {
const accessToken = await ensureAccessToken()
const user = await userManager.getUser()
if (!user || user.expired || !accessToken) {
const returnUrl =
typeof window === 'undefined' ? location.href : window.location.href
sessionStorage.setItem(PENDING_RETURN_URL_KEY, returnUrl)
await userManager.signinRedirect({ state: { returnUrl } })
throw new Error('Redirecting to login')
}
}
After signinCallback, accept only a same-origin URL whose path starts with import.meta.env.BASE_URL. Prefer the OIDC user state and use the consume-once session storage value as a fallback. Remove code, state, session_state, and iss from the callback URL before replacing it with the validated return URL. This prevents an external redirect and restores URLs such as /lowcode/dynamic/orders?view=open.
Create the router only after callback handling. A static App import creates the router too early and can make it capture /lowcode/?code=... instead of the restored dynamic route:
async function bootstrap() {
await loadRuntimeConfig()
await initUserManager()
await ensureOidcCallbackHandled()
const { default: App } = await import('./App.tsx')
createRoot(document.getElementById('root')!).render(<App />)
}
The session storage entry contains only a route URL, is scoped to the runtime origin and tab, and must be removed after success or failure. It is not an authorization token. The backend still authorizes the generated page and data endpoint.
Update Tests for the /lowcode Subpath
The generated tests describe a root-hosted application. Update their expectations together with the implementation instead of accepting a production build while leaving the test suite red:
- Route-guard tests should expect the preserved absolute browser URL, including
/lowcode, rather than only a TanStack path such as/dashboard. - Forbidden and account-navigation tests should expect a base-aware destination such as
/lowcode/403; avoid assertions that assign directly to JSDOM's unimplemented navigation API. - If you remove the generated Home or Identity Users UI, remove or rewrite the tests that still expect those screens and menu items.
- Keep OIDC callback tests for a valid same-origin
/lowcode/URL, rejection of an external or out-of-base URL, consume-once session storage, and restoration of query strings.
Run yarn lint, yarn test:run, and yarn build after these changes. Do not weaken the deep-link, authorization, or antiforgery behavior only to preserve an obsolete root-hosted test expectation.
Add Back to the Application
Read the explicit application.returnUrl value added to dynamic-env.json and fail when it is missing:
export function getHostApplicationUrl(): string {
const returnUrl = loadedConfig?.application?.returnUrl
if (!returnUrl) {
throw new Error('application.returnUrl must be configured.')
}
return returnUrl
}
Add a normal anchor to the runtime header:
<Button variant="ghost" size="sm" asChild>
<a href={getHostApplicationUrl()}>
<ArrowLeft className="size-4" />
<span>{t('AbpUi::BackToTheApplication')}</span>
</a>
</Button>
The anchor intentionally performs a full-page navigation back to the existing application. Like the source application's links into Low-Code, it discards unsaved in-memory state in the current UI.
Simplify the Companion React Shell
The generated React seed is a complete application shell, so it may contain sample or management UI that is not needed by a dedicated Low-Code runtime. You can remove these parts without changing the shared Low-Code backend.
Remove the Users Entry
To remove only Users from the sidebar, delete its item and the unused Users icon import from src/lib/routing/route-config.ts:
export const routeConfig: RouteConfigItem[] = [
{ path: '/', nameKey: 'Menu:Home', icon: Home, order: 1 },
// Dynamic items are added from useMenuItems().
]
This is a navigation-only change. An authorized user can still open /lowcode/identity/users directly while the route remains registered.
If the companion runtime should not contain the React Identity Users UI at all, also remove the following from src/routes/router.tsx:
- the
IdentityLayoutandUsersPageimports identityLayoutRoute,identityIndexRoute, andidentityUsersRoute- the composed
identityRoute identityRoutefromrootRoute.addChildren(...)
The resulting route tree should no longer contain identityRoute:
const routeTree = rootRoute.addChildren([
indexRoute,
forbiddenRoute,
accountRoute,
dynamicEntityRoute,
])
Remove now-unused imports. You can also delete src/components/identity and src/pages/identity after confirming that no other routes import them. The scoped SPA fallback still serves index.html for /lowcode/identity/users, but TanStack Router now renders its not-found component because the client route no longer exists.
Removing this React route does not uninstall the Identity module, revoke AbpIdentity.Users, or remove Identity HTTP APIs from the backend. Existing MVC, Angular, Blazor, and Admin Console management interfaces remain unchanged.
Customize or Remove Home
Replace the generated sample content in src/pages/home/HomePage.tsx with a product-specific landing page, usage instructions, or a simple branded welcome screen. For example:
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/lib/auth/AuthContext'
export function HomePage() {
const { t } = useTranslation()
const { isAuthenticated, navigateToLogin } = useAuth()
return (
<section className="rounded-xl border bg-card p-8">
<p className="text-sm font-medium text-muted-foreground">Low-Code</p>
<h1 className="mt-2 text-3xl font-semibold">Application workspace</h1>
<p className="mt-3 max-w-2xl text-muted-foreground">
Select a page from the navigation menu to work with application data.
</p>
{!isAuthenticated && (
<Button className="mt-6" onClick={navigateToLogin}>
{t('AbpAccount::Login')}
</Button>
)}
</section>
)
}
The /lowcode/ route is registered by indexRoute in src/routes/router.tsx, and its sidebar entry is the / item in route-config.ts.
To hide Home only from the sidebar, remove the / item from route-config.ts but keep indexRoute. To remove Home completely, also remove the HomePage import, the indexRoute declaration, and indexRoute from rootRoute.addChildren(...). The latter makes /lowcode/ render the client-side not-found page unless another index route replaces it.
Do not hard-code a generated page as the default unless every intended user has that page permission. The safer approach is to keep a small Home page and render links from the already-filtered useMenuItems() response. This avoids advertising or redirecting to pages that the signed-in user cannot open.
The companion Home is independent from / in the existing application and from the Admin Console home page. Customizing it changes only /lowcode/.
Keep an Account Exit
The header UserMenu can also be removed, but it normally provides logout and account actions. To replace it with only login and logout controls in src/components/layout/Header.tsx:
- Remove the
UserMenuimport. - Add
LogOutto thelucide-reactimport. - Include
logoutin the existinguseAuth()destructuring. - Replace the
UserMenubranch with the following button:
const { isAuthenticated, isLoading, login, logout } = useAuth()
// Inside the existing header actions:
{!isLoading && (
isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={() => void logout()}>
<LogOut className="size-4" />
<span className="hidden sm:inline">{t('AbpAccount::Logout')}</span>
</Button>
) : (
<Button size="sm" onClick={() => void login()}>
<LogIn className="size-4" />
{t('AbpAccount::Login')}
</Button>
)
)}
Keep the Back to the application link beside these controls. Use the existing logout() function rather than clearing browser storage or navigating directly to a logout URL; the auth context revokes the current tokens before completing the OIDC logout. Removing UserMenu hides profile and account actions only. It does not remove account routes or backend account endpoints.
Link to Designer from the Companion Runtime
An authenticated designer can also open Admin Console directly from the companion sidebar. Gate this entry with the real Designer policy; AdminConsole.Access is not a built-in permission and using it leaves the link hidden even for the administrator.
{
path: '/low-code-designer',
nameKey: 'Menu:LowCodeDesigner',
icon: ShieldCheck,
order: 6,
requiredPolicy: 'AbpLowCodeDesigner.Default',
externalHref: () =>
`${getBackendOrigin()}/admin-console/lowcode-designer`,
externalTarget: '_blank',
externalRel: 'noopener noreferrer',
}
Use the backend origin from the loaded runtime configuration rather than hard-coding a development port. Opening a new tab preserves an in-progress runtime page; use _self instead if the product deliberately wants a single application surface.
Step 4: Add UI Navigation
The existing UI does not render the Low-Code pages or Designer. Its menu entries perform full-page navigation to the two React applications. Add Menu:Dynamic and Menu:LowCodeDesigner to the existing application's localization resource.
{{if UI=="MVC"}}
MVC or Razor Pages
Add both required URLs to the startup host configuration:
{
"LowCodeUi": {
"RuntimeUrl": "https://localhost:44323/lowcode/",
"DesignerUrl": "https://localhost:44323/admin-console/lowcode-designer"
}
}
Read and validate them in the existing IMenuContributor. Make the contributor asynchronous so it can ask the Low-Code UI service whether the current user has at least one visible page:
private static async Task ConfigureMainMenuAsync(
MenuConfigurationContext context)
{
var l = context.GetLocalizer<MyProjectResource>();
var configuration = context.ServiceProvider
.GetRequiredService<IConfiguration>();
var runtimeUrl = configuration["LowCodeUi:RuntimeUrl"];
var designerUrl = configuration["LowCodeUi:DesignerUrl"];
if (string.IsNullOrWhiteSpace(runtimeUrl))
{
throw new InvalidOperationException(
"LowCodeUi:RuntimeUrl must be configured."
);
}
if (string.IsNullOrWhiteSpace(designerUrl))
{
throw new InvalidOperationException(
"LowCodeUi:DesignerUrl must be configured."
);
}
var currentUser = context.ServiceProvider.GetRequiredService<ICurrentUser>();
if (currentUser.IsAuthenticated)
{
var lowCodeUi = context.ServiceProvider
.GetRequiredService<IDynamicEntityUIAppService>();
var visibleItems = await lowCodeUi.GetMenuItemsAsync();
if (visibleItems.Count > 0)
{
context.Menu.AddItem(
new ApplicationMenuItem(
"MyProject.Dynamic",
l["Menu:Dynamic"],
runtimeUrl,
icon: "fa fa-cubes",
order: 3,
target: "_self"
).RequireAuthenticated()
);
}
}
context.Menu.AddItem(
new ApplicationMenuItem(
"MyProject.LowCodeDesigner",
l["Menu:LowCodeDesigner"],
designerUrl,
icon: "fa fa-wand-magic-sparkles",
order: 4,
target: "_self"
).RequireAuthenticated()
.RequirePermissions("AbpLowCodeDesigner.Default")
);
}
Add Volo.Abp.Users, Volo.Abp.LowCode.UI, and Volo.Abp.LowCode.Designer.Permissions imports as needed. target: "_self" deliberately replaces the MVC page with the selected React application. Using a new tab is also possible, but it creates another authenticated browser context for users to manage.
{{else if UI=="NG"}}
Angular
Add explicit URLs to both development and production environment configuration:
import { Environment } from '@abp/ng.core'
export type LowCodeEnvironment = Environment & {
lowCodeUi: {
runtimeUrl: string
designerUrl: string
}
}
export const environment = {
// Existing ABP environment values...
lowCodeUi: {
runtimeUrl: 'https://localhost:44323/lowcode/',
designerUrl: 'https://localhost:44323/admin-console/lowcode-designer',
},
} as LowCodeEnvironment
Create low-code-redirect.guard.ts:
import { Environment, EnvironmentService } from '@abp/ng.core'
import { inject } from '@angular/core'
import { CanActivateFn } from '@angular/router'
type LowCodeEnvironment = Environment & {
lowCodeUi?: {
runtimeUrl?: string
designerUrl?: string
}
}
export const lowCodeRedirectGuard: CanActivateFn = route => {
const target = route.data['lowCodeTarget']
if (target !== 'runtime' && target !== 'designer') {
throw new Error('Route data lowCodeTarget must be runtime or designer.')
}
const environment = inject(EnvironmentService)
.getEnvironment() as LowCodeEnvironment
const targetUrl = target === 'runtime'
? environment.lowCodeUi?.runtimeUrl
: environment.lowCodeUi?.designerUrl
if (!targetUrl) {
throw new Error(
`lowCodeUi.${target === 'runtime' ? 'runtimeUrl' : 'designerUrl'} must be configured.`,
)
}
window.location.assign(targetUrl)
return false
}
Register two componentless routes. Angular 21 requires children, a component, a redirect, or a lazy loader even when a guard always leaves the application:
{
path: 'dynamic',
canActivate: [authGuard, lowCodeRedirectGuard],
data: { lowCodeTarget: 'runtime' },
children: [],
},
{
path: 'low-code-designer',
canActivate: [authGuard, permissionGuard, lowCodeRedirectGuard],
data: {
lowCodeTarget: 'designer',
requiredPolicy: 'AbpLowCodeDesigner.Default',
},
children: [],
},
Register Dynamic as initially invisible. Give Designer its actual permission requirement. Then patch Dynamic after the authenticated user's filtered Low-Code menu has loaded:
const DYNAMIC_MENU_NAME = '::Menu:Dynamic'
routes.add([
{
path: '/dynamic',
name: DYNAMIC_MENU_NAME,
iconClass: 'fas fa-cubes',
order: 3,
layout: eLayoutType.application,
invisible: true,
},
{
path: '/low-code-designer',
name: '::Menu:LowCodeDesigner',
iconClass: 'fas fa-wand-magic-sparkles',
order: 4,
layout: eLayoutType.application,
requiredPolicy: 'AbpLowCodeDesigner.Default',
},
])
configState.getOne$('currentUser').pipe(
map(currentUser => currentUser?.isAuthenticated === true),
distinctUntilChanged(),
switchMap(isAuthenticated =>
isAuthenticated
? rest.request<void, DynamicMenuItemDefinition[]>(
{
method: 'GET',
url: '/api/low-code/ui/menu-items',
},
{ apiName: 'Default' },
).pipe(catchError(() => of([])))
: of([]),
),
).subscribe(menuItems => {
routes.patch(DYNAMIC_MENU_NAME, {
invisible: menuItems.length === 0,
})
})
Inject ConfigStateService as configState and RestService as rest; import catchError, distinctUntilChanged, map, of, and switchMap from RxJS. Define DynamicMenuItemDefinition with at least the response fields your code consumes. The request is not sent for anonymous users. An API error hides Dynamic rather than exposing an unverified entry, and the menu may appear asynchronously after authentication and configuration loading. The destination React runtime and Admin Console still enforce their own authorization.
When Angular is hosted on another origin, add that exact origin to App:CorsOrigins and seed an explicit public OIDC client with the Angular callback and post-logout URLs. Do not treat localhost and 127.0.0.1 as interchangeable origins.
{{else if UI=="Blazor" || UI=="BlazorServer" || UI=="BlazorWebApp"}}
Blazor
Add both URLs to the configuration consumed by the Blazor menu contributor:
{
"LowCodeUi": {
"RuntimeUrl": "https://localhost:44323/lowcode/",
"DesignerUrl": "https://localhost:44323/admin-console/lowcode-designer"
}
}
For Blazor Web App with interactive Auto or WebAssembly rendering, add the same section to both the server appsettings.json and the client wwwroot/appsettings.json. Otherwise, the menu can work during server rendering and fail after the client becomes interactive.
Inject IConfiguration into the existing IMenuContributor, validate the URLs, and use the same filtered-menu and Designer permission checks as MVC:
private readonly IConfiguration _configuration;
public MyProjectMenuContributor(IConfiguration configuration)
{
_configuration = configuration;
}
private async Task ConfigureMainMenuAsync(MenuConfigurationContext context)
{
var l = context.GetLocalizer<MyProjectResource>();
var runtimeUrl = _configuration["LowCodeUi:RuntimeUrl"];
var designerUrl = _configuration["LowCodeUi:DesignerUrl"];
if (string.IsNullOrWhiteSpace(runtimeUrl))
{
throw new InvalidOperationException("LowCodeUi:RuntimeUrl must be configured.");
}
if (string.IsNullOrWhiteSpace(designerUrl))
{
throw new InvalidOperationException("LowCodeUi:DesignerUrl must be configured.");
}
var currentUser = context.ServiceProvider.GetRequiredService<ICurrentUser>();
if (currentUser.IsAuthenticated)
{
var lowCodeUi = context.ServiceProvider
.GetRequiredService<IDynamicEntityUIAppService>();
var visibleItems = await lowCodeUi.GetMenuItemsAsync();
if (visibleItems.Count > 0)
{
context.Menu.AddItem(
new ApplicationMenuItem(
"MyProject.Dynamic",
l["Menu:Dynamic"],
runtimeUrl,
icon: "fa fa-cubes",
order: 3,
target: "_self"
).RequireAuthenticated()
);
}
}
if (await context.IsGrantedAsync(
AbpLowCodeDesignerPermissions.Designer.Default))
{
context.Menu.AddItem(
new ApplicationMenuItem(
"MyProject.LowCodeDesigner",
l["Menu:LowCodeDesigner"],
designerUrl,
icon: "fa fa-wand-magic-sparkles",
order: 4,
target: "_self"
).RequireAuthenticated()
);
}
}
The menu contributor needs the Low-Code contracts and HTTP client proxy. If Studio's backend import did not add them to the Blazor UI projects, add Volo.Abp.LowCode.HttpApi.Client and Volo.Abp.LowCode.Designer.Application.Contracts, then depend on AbpLowCodeHttpApiClientModule and AbpLowCodeDesignerApplicationContractsModule. Without the contracts module, context.IsGrantedAsync can fail because the Designer policy definition is unknown rather than simply returning false.
For a separate Blazor client project, configure the Low-Code remote service explicitly in both server and client configuration:
{
"RemoteServices": {
"LowCode": {
"BaseUrl": "https://localhost:44323"
}
}
}
Use the same backend and authority for the Blazor UI and Low-Code service so the proxy sends the current user's access token and the returned menu is filtered for that user. A separate authority requires an explicit token-exchange or delegated-authentication design and is outside this guide.
The Blazor Web App template registers the same contributor in its server and client modules. Keep that existing registration; do not register a second contributor only for Low-Code.
{{end}}
The original unconditional menu examples were visible before login because no authentication or permission rule was attached to them. The revised code has three deliberate layers: anonymous users see neither entry, Dynamic appears only when the filtered response contains at least one visible page or group, and Designer requires AbpLowCodeDesigner.Default.
GetMenuItemsAsync() reads Low-Code page metadata and applies the current user's generated page permissions; it does not query every row from a generated business entity table. MVC and Blazor await this call while composing the menu. Angular starts with the entry hidden and patches it after the response. A failed menu lookup therefore hides the convenience link, but it does not make a permitted page inaccessible by URL.
These checks improve navigation only. Dynamic page APIs enforce their generated runtime permissions, while Designer enforces AbpLowCodeDesigner.Default plus any edit or script-test permissions used by the user. Full-page navigation also discards unsaved in-memory UI state in the source MVC, Angular, or Blazor application.
Deployment Alternatives
Same Host and Subpath: Recommended
Serving React at /lowcode avoids a second origin, minimizes CORS configuration, and lets MVC plus React use the same authority and API origin. It still requires separate OIDC client IDs because their callback paths and lifecycle are different.
Separate React Origin
Hosting React at a separate origin also works. In that case:
- add the exact React origin to backend CORS configuration
- add its exact redirect, silent renew, and logout URIs to OpenIddict
- set the MVC or Blazor
LowCodeUi:RuntimeUrland AngularlowCodeUi.runtimeUrlto that origin - validate cookies, SameSite behavior, reverse-proxy headers, and HTTPS end to end
Do not use wildcard callback URLs.
Iframe
An iframe is not recommended. Authentication cookies, Content Security Policy, X-Frame-Options, focus handling, downloads, and full-page designer/runtime navigation are harder to make reliable. Prefer a normal same-tab navigation.
Permissions, Tenancy, and Culture
- Designer access is controlled by
AbpLowCodeDesigner.*permissions. - Generated pages and endpoints must enforce their generated runtime permissions independently.
- The companion React client must request the existing backend API scope.
- In a multi-tenant solution, test host and tenant login, tenant switching, and logout separately.
- Preserve the application's normal culture and UI-culture handling through the OIDC flow.
- Never hide a menu item as the only authorization check.
Validation Checklist
Run the backend checks:
dotnet build Acme.NonReactLowCode.slnx
dotnet run `
--project src\Acme.NonReactLowCode.Web\Acme.NonReactLowCode.Web.csproj `
--no-build -- --check-lowcode-model-files
Push-Location src\Acme.NonReactLowCode.DbMigrator
dotnet run --no-build
Pop-Location
Running from the DbMigrator project directory ensures a project-local appsettings.secrets.json is discovered. If a commercial module exits with ABP-LIC-0020, verify the working directory and secret configuration without printing or moving the license into source-controlled settings.
Build the companion React runtime for every UI choice:
cd lowcode-react
yarn install --frozen-lockfile
yarn lint
yarn test:run
yarn build
{{if UI=="NG"}}
Build Angular separately:
cd ..\angular
yarn build
{{else if UI=="MVC" || UI=="Blazor" || UI=="BlazorServer" || UI=="BlazorWebApp"}}
The MVC or Blazor UI is already included in the preceding .NET solution build.
{{end}}
Verify these behaviors in a browser:
/still opens the existing UI.- Anonymous MVC, Angular, and Blazor users see neither
DynamicnorLow-Code Designer. - An authenticated user with no visible generated page does not see
Dynamic. - A user with at least one visible page sees
Dynamic; a user withAbpLowCodeDesigner.DefaultseesLow-Code Designer. /admin-console/api/modulesreturnstrueonly for the explicitly allowed modules./admin-console/lowcode-designeropens for an authorized administrator, while an omitted module route shows the Admin Console404page.- Anonymous
/lowcode/shows no dynamic menu and does not request/api/low-code/ui/menu-items. - Opening
/lowcode/dynamic/<page-name>anonymously starts login and returns to that exact deep link after authentication. - Refreshing the dynamic deep link returns the SPA HTML with
Cache-Control: no-store, no-cache, must-revalidateand does not return404. - The signed-in runtime shows only the filtered dynamic menu tree and can render the permitted page.
- Back to the application navigates from
/lowcode/to the configured existing application URL. DynamicandLow-Code Designeruse full-page navigation to their React applications.- A user without a generated page permission cannot open that page or its data API directly.
- A Designer create/update mutation sends
RequestVerificationTokenand succeeds; a runtime record create/update also succeeds without a302or/Error?httpStatusCode=400redirect. - The companion
Low-Code Designerlink is controlled byAbpLowCodeDesigner.Default, not a synthetic Admin Console permission. - Admin Console's module discovery response enables only
lowCodeDesigner; its static home cards may still advertise hidden modules whose direct routes return404.
Troubleshooting
| Symptom | Cause and correction |
|---|---|
| Designer route is missing | Add Volo.Abp.AdminConsole, AbpAdminConsoleModule, and the Volo.Abp.LowCode.Designer.* layer packages. |
Designer returns 403 |
Re-run data seeding and verify AbpLowCodeDesigner.Default for the role. Restart and sign in again after changing grants. |
| Hidden Admin Console modules reappear after an ABP upgrade | Compare the replacement controller's keys with the upgraded discovery controller and React route configuration. Do not add unknown keys without checking their route and permission behavior. |
| Admin Console home links to hidden modules | The built-in home content is static. Link users directly to /admin-console/lowcode-designer; use a custom Admin Console frontend if the root page must also be redesigned. |
| Companion React does not show its Designer link for admin | Gate the link with AbpLowCodeDesigner.Default. AdminConsole.Access is not a built-in permission. |
/lowcode/ loads but assets return 404 |
Set Vite base to /lowcode/ and rebuild. |
/lowcode/ becomes blank after deploying a new build |
The browser may have cached fallback HTML that points to deleted hashed assets. Send the documented no-cache headers for fallback index.html; keep normal hashed-asset caching. |
A Low-Code create/update/delete request becomes 302, then /Error?httpStatusCode=400 |
The endpoint exists, but ABP rejected the mutation because RequestVerificationToken was missing. Configure Axios with XSRF-TOKEN and RequestVerificationToken; do not disable antiforgery globally. |
| React navigation works until browser refresh | Add the scoped MapFallbackToFile route. |
| OIDC says the redirect URI is invalid | Seed the trailing-slash callback and silent-renew.html callback for the dedicated runtime client. |
Login from a dynamic deep link returns to /lowcode/ |
Store window.location.href, validate it against the runtime origin and base path after callback, and create the router only after callback handling. |
| Menu entries are visible before login | Add the authentication and permission rules shown in Step 4. An external URL alone has no implicit authorization requirement. |
Dynamic is visible although the user has no visible page |
Do not add it unconditionally. Use the filtered /api/low-code/ui/menu-items response and hide the entry when the root list is empty. |
Angular fails with NG04014 for dynamic |
Add children: [] or a redirect component to the componentless guard route. |
Angular API calls fail only from 127.0.0.1 |
Use the configured localhost origin or add the exact origin to CORS; origins are not interchangeable. |
Angular Dynamic remains hidden after login |
Verify the Default API URL, access token, menu-items request, and the RoutesService.patch name. The fail-closed example intentionally remains hidden on request failure. |
| Blazor menu disappears after the page becomes interactive | In Blazor Web App interactive Auto or WebAssembly mode, add the same LowCodeUi section to the client wwwroot/appsettings.json as well as the server configuration. |
Blazor throws No policy found for Designer |
Add the Designer application contracts package and AbpLowCodeDesignerApplicationContractsModule to the UI host/client that composes the menu. |
| Generated React seed fails to type-check test imports | Inspect its Testing Library peer dependencies. Add a compatible @testing-library/dom only when it is missing from the generated application. |
Companion title or menu still shows the temporary project name or Menu:LowCodeDesigner |
Rename index.html, package/application identity, and the copied src/locales/*.json resource. Confirm that the localization key exists in the resource loaded by React, not only in the existing UI's resource. |
Production build passes but companion tests fail after moving to /lowcode |
Update root-hosted guard, redirect, OIDC-return, and removed-screen expectations for the subpath. Run lint and tests in addition to the build. |
DbMigrator exits with ABP-LIC-0020 although a secrets file exists |
Run it from the project directory containing appsettings.secrets.json, or use the deployment's supported secret provider. Never commit or print the license value. |