DEV Community

Cover image for Deep Linking in Power Apps Code Apps: Yes, It *Is* Possible (With a Twist)
Riccardo Gregori
Riccardo Gregori

Posted on

Deep Linking in Power Apps Code Apps: Yes, It *Is* Possible (With a Twist)

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
Enter fullscreen mode Exit fullscreen mode

or Hash Routing:

#/accounts
#/accounts/123
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

๐Ÿ‘‰ https://dianabirkelbach.wordpress.com/2026/02/28/how-to-make-deep-links-with-code-apps-and-call-them-from-model-driven-apps/

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Define deterministic routes in the application.
  2. Read query string parameters during startup.
  3. Navigate to the corresponding route.
  4. Generate shareable URLs at runtime.
  5. 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,
  },
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

The logic is straightforward:

  • Case 1, no parameters:
    • https://apps.powerapps.com/play/e/<envId>/app/<appId> --> routes to /dashboard
  • Case 2, etn only:
    • 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 };
}

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

and, optionally:

id=<record-id>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)