In my previous article, I wrote the following:
Achieving equivalent behavior in Code Apps is, as of today, โ practically impossible โ because of the way the application is hosted and executed within the Power Apps ecosystem. This topic deserves an article of its own... stay tuned.
Well... it turns out that statement was not entirely correct ๐ .
Or, more precisely: the problem is real, but there's a workaround that makes deep linking not only possible, but actually quite elegant once you understand how Power Apps hosts a Code App.
Let's see what's really happening under the hood.
๐ฆ The "URL Problem" Isn't Really About Routing
ย
When we build a traditional React application, client-side navigation is usually straightforward.
Whether you use Browser Routing:
/accounts
/accounts/123
or Hash Routing:
#/accounts
#/accounts/123
the URL visible in the browser changes as the user navigates through the application.
A Power Apps Code App behaves differently. What we see in the browser:
https://apps.powerapps.com/play/e/<environmentId>/app/<appId>
is not actually the URL of our React application, it is the URL of the Power Apps host page.
Our application is loaded inside an iframe, embedded by the Power Apps runtime.
Visually it feels like we're browsing a normal web application, but in reality we're interacting with a page running inside a container managed by Power Apps.
This means that when React Router updates the URL, it only updates the location inside the iframe, not the URL of the outer Power Apps host page.
As a consequence:
- Browser routing works internally.
- Hash routing works internally.
- React Router behaves correctly.
- The URL shown in the browser never changes.
This is exactly why a deep link copied from the address bar always brings us back to the application's home page. The route changes. The visible URL does not.
๐ก A hint from a friend
After publishing my previous article, Diana Birkelbach (thanks @diana! ๐๐ป) left a LinkedIn comment pointing me to her article:
The more I looked at her approach, the more I realized that the important part wasn't the implementation itself. The key discovery is how Power Apps handles query string parameters.
If you open a Code App through a URL like:
https://apps.powerapps.com/play/e/<envId>/app/<appId>?param=value
the parameter gets forwarded from the Power Apps host page to the embedded application. Inside the app, you can access it using:
(await getContext()).app.queryParams
And that's where the light bulb went on ๐ก.
If Power Apps already forwards arbitrary query string parameters to the application running inside the iframe, we can use those parameters as a contract between the host page and our React router.
The approach is surprisingly simple:
- Define deterministic routes in the application.
- Read query string parameters during startup.
- Navigate to the corresponding route.
- Generate shareable URLs at runtime.
- Use those URLs in buttons such as "Open in New Tab" or "Copy Link".
Let's build it.
๐บ๏ธ Step 1: Define Consistent Routes
My application's router looks like this:
export const router = createBrowserRouter(
[
{
path: "/",
element: <AppLayout />,
errorElement: <NotFoundPage />,
children: [
{ index: true, element: <Home /> },
{ path: "dashboard", element: <DashboardPage /> },
{ path: "accounts", element: <AccountsPage /> },
{ path: "accounts/:id", element: <AccountDetailPage /> },
{ path: "contacts", element: <ContactsPage /> },
{ path: "contacts/:id", element: <ContactDetailPage /> },
...
{ path: "settings", element: <UserSettingsPage /> },
],
},
],
{
basename: BASENAME,
},
);
The important detail is not the router itself, it's the route convention. I use:
-
/<table-name>for grids and lists -
/<table-name>/<record-id>for record detail pages
Example:
/accounts
/accounts/cb87da63-2cb6-4ce5-b04f-0db22cd905a4
/contacts
/contacts/c627c9b4-0bda-413f-8f38-6ec2d6e4c72f
Keeping the route structure deterministic is essential because later we'll reconstruct it dynamically from query parameters. If every page follows the same convention, generating links becomes trivial.
๐ช Step 2: Redirect from the Home Page
The **Home **component becomes our entry point. Its responsibility is simple:
- Read Power Apps query parameters.
- Translate them into a React route.
- Redirect the user accordingly.
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { getContext } from "@microsoft/power-apps/app";
export function Home() {
const navigate = useNavigate();
useEffect(() => {
const fetchContext = async () => {
try {
const ctx = await getContext();
const queryParams = ctx.app.queryParams;
if (!queryParams) {
navigate('/dashboard');
return;
}
const etn = queryParams["etn"];
if (!etn) {
navigate('/dashboard');
return;
}
const id = queryParams["id"];
const url = id ? `/${etn}/${id}` : `/${etn}`;
navigate(url);
} catch (error) {
console.error(error);
navigate('/dashboard');
}
};
fetchContext();
}, [navigate]);
}
The logic is straightforward:
-
Case 1, no parameters:
-
https://apps.powerapps.com/play/e/<envId>/app/<appId>--> routes to/dashboard
-
-
Case 2,
etnonly:-
https://apps.powerapps.com/play/e/<envId>/app/<appId>?etn=accounts--> routes to/accounts
-
-
Case 2,
etn+id:-
https://apps.powerapps.com/play/e/<envId>/app/<appId>?etn=accounts&id=cb87da63-2cb6-4ce5-b04f-0db22cd905a4--> routes to/accounts/cb87da63-2cb6-4ce5-b04f-0db22cd905a4
-
At this point we have something that behaves very much like a deep link. The only difference is that the route is encoded as query parameters in the Power Apps host URL.
๐งฎ Step 3: Create a Hook That Generates Shareable URLs
Now comes the interesting part.
Suppose you're already inside a record page. How do you generate the URL that another user can click?
We need a way to reconstruct the outer Power Apps URL while encoding the current route as query parameters.
That's exactly what this hook does.
// useCodeAppUrl.ts
import { useEffect, useState } from "react";
import { getContext } from "@microsoft/power-apps/app";
export interface UseCodeAppUrlResult {
url: string | null;
isLocal: boolean;
loading: boolean;
error: Error | null;
}
export function useCodeAppUrl(
tableName: string,
id?: string,
): UseCodeAppUrlResult {
const [url, setUrl] = useState<string | null>(null);
const [isLocal, setIsLocal] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const ctx = await getContext();
const { appId, environmentId, queryParams } = ctx.app;
let next = `https://apps.powerapps.com/play/e/${environmentId}/a/${appId}?`;
const local = appId === "local";
if (local) {
next += `_localAppUrl=${queryParams._localAppUrl}` +
`&_localConnectionUrl=${queryParams._localConnectionUrl}&`;
}
next += `etn=${encodeURIComponent(tableName)}`;
if (id) {
next += `&id=${encodeURIComponent(id)}`;
}
if (!cancelled) {
setUrl(next);
setIsLocal(local);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e : new Error(String(e)));
setUrl(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [tableName, id]);
return { url, isLocal, loading, error };
}
Let's break it down.
๐ Retrieve application metadata
First we obtain the current Power Apps context:
const ctx = await getContext();
const { appId, environmentId } = ctx.app;
These values uniquely identify the running Code App.
๐ Build the Host URL
We reconstruct the same URL used by Power Apps:
https://apps.powerapps.com/play/e/${environmentId}/a/${appId}
This is the URL of the container hosting our application.
Remember: we're generating the address of the box, not of the React app inside it.
๐งช Handle Local Development
The hook also supports local execution:
const local = appId === "local";
When running through the Code Apps local development experience, Power Apps injects special parameters such as _localAppUrl and _localConnectionUrl. Without those parameters the generated URLs would stop working during development... This small piece of logic allows the same hook to work seamlessly both locally and when hosted on the Power Apps portal.
๐จ Encode Navigation Parameters
Finally we add:
etn=<table-name>
and, optionally:
id=<record-id>
resulting in URLs such as:
https://apps.powerapps.com/play/e/.../a/...?
etn=accounts
https://apps.powerapps.com/play/e/.../a/...?
etn=accounts&id=cb87da63-2cb6-4ce5-b04f-0db22cd905a4
When opened, the Home page interprets those parameters and redirects the user to the desired route.
Mission accomplished โ
๐ Step 4: Create an "Open in New Tab" Button
Now that URL generation is centralized, building reusable components becomes easy.
The first example is an "Open in New Tab" button:
import {
Copy,
ExternalLink,
Loader2
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCodeAppUrl } from "@/hooks/use-code-app-url";
export interface IProps {
tableName: string;
id?: string;
}
export function OpenInNewTabButton({ tableName, id }: IProps ) {
const { url, loading, error } = useCodeAppUrl(tableName, id);
const label = "Open in new tab";
if (loading) {
return (
<Button
variant="outline"
size="icon-sm"
disabled
aria-label={label}
title={label}
>
<Loader2 size={14} className="animate-spin" />
</Button>
);
}
if (error || !url) {
return (
<Button
variant="outline"
size="icon-sm"
disabled
aria-label={label}
title={label}
>
<ExternalLink size={14} />
</Button>
);
}
return (
<Button variant="outline" size="icon-sm" asChild>
<a href={url} target="_blank" rel="noreferrer" aria-label={label} title={label}>
<ExternalLink size={14} />
</a>
</Button>
);
}
The component follows three states:
- โณ Loading: while the hook is calculating the URL, the button remains disabled.
- โ Error: If the URL cannot be generated, the button is displayed in a disabled state.
- โ Ready: Once the URL is available, the user can open the exact page in a new browser tab.
The nice thing about this approach is that the component doesn't know anything about routing. All routing complexity lives in the hook. Simple, accessible and fully reusable, any page in your application can immediately become shareable.
๐ Bonus: Add a "Copy Link" Button
Opening a new tab is useful. Copying the current page URL is often even more useful.
Using the same hook we can build another reusable component:
export function CopyLinkButton({ tableName, id }: IProps) {
const { url, loading, error } = useCodeAppUrl(tableName, id);
const label = "Copy link";
const disabled = loading || !!error || !url;
return (
<Button
variant="outline"
size="icon-sm"
disabled={disabled}
aria-label={label}
title={label}
onClick={async () => {
if (!url) return;
await navigator.clipboard.writeText(url);
toast.success("Link copied"); // custom toast notification service provided by the app, use your own way to notify the user
}}
>
{loading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Copy size={14} />
)}
</Button>
);
}
When clicked:
-
await navigator.clipboard.writeText(url);--> copies the generated Power Apps URL directly into the clipboard. -
toast.success("Link copied");--> a toast notification then informs the user that the operation completed successfully
How you notify the user is entirely up to you. In my case I'm using a custom toast service, but any notification mechanism will work.
The result is a surprisingly powerful little feature.
Users can now:
- paste links into Teams;
- send them through email;
- bookmark specific records;
- reference entities from documentation;
- add links to work items and tickets.
All without needing to understand anything about how Code Apps are hosted.
๐ฏ Conclusions
The good news is that deep linking in Power Apps Code Apps is not impossible.
The bad news is that it doesn't work the way we'd naturally expect from a traditional React application. Because our app runs inside a Power Apps-hosted iframe, browser routing alone cannot update the visible URL... However, by leveraging the query string forwarding mechanism exposed by Power Apps, we can build a robust deep-linking strategy that:
- supports grids and record forms
- works with React Router
- supports local development
- enables "Open in New Tab"
- enables "Copy Link"
- generates stable, shareable URLs
In other words, we don't control the URL of the application itself.
But we can somehow control the URL of the box that hosts it.
Is it as elegant as controlling the browser URL directly?
No.
Does it work reliably?
Absolutely.
And honestly, if you've spent enough time on the Power Platform, you know that sometimes finding the right seam in the abstraction is more useful than fighting the abstraction itself ๐
Top comments (0)