Repository: pwrdrvr/microapps-core (language: python)

Issue:
router - Demo routing localized app paths
## To-Do
- [x] Update `edge-to-origin` to allow a list of locale prefix directories
- [x] router-lib - Strip the locale prefix before looking up the appname and version
- [x] router-lib - Strip the locale prefix on `/_next/data/0.7.0/en/nextjs-demo/posts/ssg-ssr.json?id=ssg-ssr`
- [x] Add a CloudFront behavior for `/sv/` manually to start testing
- [x] Update CDK constructs to create either `*` locale route or list of locale routes
- [x] Add/update tests as necessary
- [x] Manually test the nextjs-demo app with localization

## Notes
- This is not the same as the `prefix` functionality
  - `prefix` applies to everything: static files, api routes, `_next/data`, pages, everything
  - `locales` only apply to page paths
- Example paths with locales for app without `basePath` set
  - Page: `/sv/nextjs-demo`
  - `_next/data`: `/_next/data/development/sv/nextjs-demo/post`
  - Images: `/nextjs-demo/0.0.0/images/profile.jpg`
  - Manifest: `/nextjs-demo/0.0.0/_next/static/development/_buildManifest.js`
  - Page JS Chunk: `/nextjs-demo/0.0.0/_next/static/chunks/pages/nextjs-demo/posts/%5Bid%5D.js`
- CloudFront Behavior Implications:
  - Only 1 new `/*/[something]` route needs to be added for Pages


Discussion / hints:

Fix patch:
diff --git a/packages/cdk/lib/MicroApps.ts b/packages/cdk/lib/MicroApps.ts
index f75aa5ae..ee792acb 100644
--- a/packages/cdk/lib/MicroApps.ts
+++ b/packages/cdk/lib/MicroApps.ts
@@ -248,6 +248,7 @@ export class MicroAppsStack extends Stack {
       table: table.table,
       tableNameForEdgeToOrigin: tableName ? tableName : `${assetNameRoot}${assetNameSuffix}`,
       allowedFunctionUrlAccounts,
+      allowedLocalePrefixes: ['en', 'sv'],
       ...optionalAssetNameOpts,
       ...optionals3PolicyOpts,
       ...optionalCustomDomainOpts,
diff --git a/packages/cdk/package.json b/packages/cdk/package.json
index 4f569951..1ff66f30 100644
--- a/packages/cdk/package.json
+++ b/packages/cdk/package.json
@@ -14,7 +14,7 @@
     "cdk": "cdk"
   },
   "dependencies": {
-    "@pwrdrvr/microapps-app-nextjs-demo-cdk": "0.6.3",
+    "@pwrdrvr/microapps-app-nextjs-demo-cdk": "0.7.0",
     "@pwrdrvr/microapps-app-release-cdk": "0.5.3",
     "source-map-support": "0.5.21",
     "aws-cdk-lib": "2.24.1",
diff --git a/packages/microapps-cdk/src/MicroApps.ts b/packages/microapps-cdk/src/MicroApps.ts
index 33a46170..7ad1cc80 100644
--- a/packages/microapps-cdk/src/MicroApps.ts
+++ b/packages/microapps-cdk/src/MicroApps.ts
@@ -302,6 +302,14 @@ export interface MicroAppsProps {
    * @default []
    */
   readonly allowedFunctionUrlAccounts?: string[];
+
+  /**
+   * List of allowed locale prefixes for pages
+   *
+   * @example: ['en', 'fr', 'es']
+   * @default none
+   */
+  readonly allowedLocalePrefixes?: string[];
 }
 
 /**
@@ -459,6 +467,7 @@ export class MicroApps extends Construct implements IMicroApps {
         rootPathPrefix,
         tableRulesArn: tableNameForEdgeToOrigin || this._svcs.table.tableName,
         allowedFunctionUrlAccounts,
+        allowedLocalePrefixes: props.allowedLocalePrefixes,
       });
 
       edgeLambdas.push(...this._edgeToOrigin.edgeToOriginLambdas);
diff --git a/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts b/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts
index 894ed9b1..8ee846ce 100644
--- a/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts
+++ b/packages/microapps-cdk/src/MicroAppsEdgeToOrigin.ts
@@ -78,6 +78,14 @@ export interface MicroAppsEdgeToOriginProps {
    */
   readonly rootPathPrefix?: string;
 
+  /**
+   * List of allowed locale prefixes for pages
+   *
+   * @example: ['en', 'fr', 'es']
+   * @default none
+   */
+  readonly allowedLocalePrefixes?: string[];
+
   /**
    * Adds an X-Forwarded-Host-Header when calling API Gateway
    *
@@ -157,6 +165,7 @@ export interface GenerateEdgeToOriginConfigOptions {
   readonly replaceHostHeader: boolean;
   readonly tableName?: string;
   readonly rootPathPrefix?: string;
+  readonly locales?: string[];
 }
 
 interface IMicroAppsEdgeToOriginRoleStackProps extends StackProps {
@@ -283,7 +292,12 @@ ${props.signingMode === '' ? '' : `signingMode: ${props.signingMode}`}
 addXForwardedHostHeader: ${props.addXForwardedHostHeader}
 replaceHostHeader: ${props.replaceHostHeader}
 ${props.tableName ? `tableName: '${props.tableName}'` : ''}
-${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}`;
+${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}
+${
+  props.locales && props.locales.length > 0
+    ? `locales: [${props.locales.map((locale) => `'${locale}'`).join(', ')}]`
+    : ''
+}`;
   }
 
   private _edgeToOriginFunction: lambda.Function | cf.experimental.EdgeFunction;
@@ -329,6 +343,7 @@ ${props.rootPathPrefix ? `rootPathPrefix: '${props.rootPathPrefix}'` : ''}`;
       replaceHostHeader,
       signingMode: signingMode === 'none' ? '' : signingMode,
       rootPathPrefix,
+      locales: props.allowedLocalePrefixes,
       ...(tableRulesArn
         ? {
             tableName: tableRulesArn,
diff --git a/packages/microapps-edge-to-origin/src/config/config.spec.ts b/packages/microapps-edge-to-origin/src/config/config.spec.ts
index 66b68a69..3bbc1eb4 100644
--- a/packages/microapps-edge-to-origin/src/config/config.spec.ts
+++ b/packages/microapps-edge-to-origin/src/config/config.spec.ts
@@ -18,6 +18,7 @@ describe('config', () => {
       signingMode: 'sign',
       tableName: 'microapps',
       rootPathPrefix: '',
+      locales: [],
     };
     const loader = new TSConvict<Config>(Config);
     const config = loader.load(rawConfig);
diff --git a/packages/microapps-edge-to-origin/src/config/config.ts b/packages/microapps-edge-to-origin/src/config/config.ts
index f55c95a8..e353aa55 100644
--- a/packages/microapps-edge-to-origin/src/config/config.ts
+++ b/packages/microapps-edge-to-origin/src/config/config.ts
@@ -48,6 +48,16 @@ export interface IConfig {
    * Path prefix on the root of the MicroApps deployment
    */
   readonly rootPathPrefix: string;
+
+  /**
+   * List of allowed locale prefixes for pages
+   * If there is a match then the 1st path after the `rootPathPrefix` is
+   * removed from the path before looking up the AppName and Version.
+   *
+   * @example ['en', 'fr', 'es']
+   * @default none
+   */
+  readonly locales: string[];
 }
 
 /**
@@ -171,4 +181,11 @@ export class Config implements IConfig {
     env: 'ROOT_PATH_PREFIX',
   })
   public rootPathPrefix!: string;
+
+  @convict.Property({
+    doc: 'Allowed locales for pages',
+    default: [],
+    env: 'LOCALES',
+  })
+  public locales!: string[];
 }
diff --git a/packages/microapps-edge-to-origin/src/index.route.prefix.spec.ts b/packages/microapps-edge-to-origin/src/index.route.prefix.spec.ts
index e0f816b9..ef9dbd2f 100644
--- a/packages/microapps-edge-to-origin/src/index.route.prefix.spec.ts
+++ b/packages/microapps-edge-to-origin/src/index.route.prefix.spec.ts
@@ -17,6 +17,7 @@ const theConfig: Writeable<IConfig> = {
   signingMode: 'sign',
   tableName: '',
   rootPathPrefix: '/prefix',
+  locales: [],
 };
 const origConfig = { ...theConfig };
 Object.defineProperty(Config, 'instance', {
diff --git a/packages/microapps-edge-to-origin/src/index.route.root.spec.ts b/packages/microapps-edge-to-origin/src/index.route.root.spec.ts
index b6b3ee53..858566cd 100644
--- a/packages/microapps-edge-to-origin/src/index.route.root.spec.ts
+++ b/packages/microapps-edge-to-origin/src/index.route.root.spec.ts
@@ -15,6 +15,7 @@ const theConfig: Writeable<IConfig> = {
   signingMode: 'sign',
   tableName: '',
   rootPathPrefix: '',
+  locales: [],
 };
 
 let dynamoClient: dynamodb.DynamoDBClient;
diff --git a/packages/microapps-edge-to-origin/src/index.route.spec.ts b/packages/microapps-edge-to-origin/src/index.route.spec.ts
index a2e489cd..4f838c93 100644
--- a/packages/microapps-edge-to-origin/src/index.route.spec.ts
+++ b/packages/microapps-edge-to-origin/src/index.route.spec.ts
@@ -16,6 +16,7 @@ const theConfig: Writeable<IConfig> = {
   signingMode: 'sign',
   tableName: '',
   rootPathPrefix: '',
+  locales: [],
 };
 const origConfig = { ...theConfig };
 Object.defineProperty(Config, 'instance', {
@@ -291,34 +292,151 @@ describe('edge-to-origin - routing - without prefix', () => {
     expect(requestResponse?.origin?.custom?.domainName).toBe('abc123.lambda-url.us-east-1.on.aws');
   });
 
+  it('should route `direct` app request with *locale* to origin for appName', async () => {
+    theConfig.replaceHostHeader = true;
+    theConfig.locales = ['en', 'sv'];
+
+    const AppName = 'BatDirect';
+    const SemVer = '1.2.1-beta.1';
+    const Locale = 'sv';
+
+    const app = new Application({
+      AppName,
+      DisplayName: 'Direct Bat App',
+    });
+    await app.Save(dbManager);
+
+    const version = new Version({
+      AppName,
+      SemVer,
+      Status: 'deployed',
+      Type: 'lambda-url',
+      StartupType: 'direct',
+      URL: 'https://abc123.lambda-url.us-east-1.on.aws/',
+    });
+    await version.Save(dbManager);
+
+    const rules = new Rules({
+      AppName,
+      Version: 0,
+      RuleSet: { default: { SemVer, AttributeName: '', AttributeValue: '' } },
+    });
+    await rules.Save(dbManager);
+
+    // Call the handler
+    // @ts-expect-error no callback
+    const response = await handler(
+      {
+        Records: [
+          {
+            cf: {
+              config: {
+                distributionDomainName: 'zyz.cloudfront.net',
+                distributionId: '123',
+                eventType: 'origin-request',
+                requestId: '123',
+              },
+              request: {
+                headers: {
+                  host: [
+                    {
+                      key: 'Host',
+                      value: 'zyz.cloudfront.net',
+                    },
+                  ],
+                },
+                method: 'GET',
+                querystring: '',
+                clientIp: '1.1.1.1',
+                uri: `/${Locale}/${AppName.toLowerCase()}`,
+                origin: {
+                  custom: {
+                    customHeaders: {},
+                    domainName: 'zyz.cloudfront.net',
+                    keepaliveTimeout: 5,
+                    path: '',
+                    port: 443,
+                    protocol: 'https',
+                    readTimeout: 30,
+                    sslProtocols: ['TLSv1.2'],
+                  },
+                },
+              },
+            },
+          },
+        ],
+      } as lambda.CloudFrontRequestEvent,
+      {} as lambda.Context,
+    );
+
+    const requestResponse = response as lambda.CloudFrontRequest;
+    expect(requestResponse).toBeDefined();
+    expect(requestResponse).not.toHaveProperty('status');
+    expect(requestResponse).not.toHaveProperty('body');
+    expect(requestResponse).toHaveProperty('headers');
+    expect(requestResponse.headers['x-microapps-appname'][0].key).toBe('X-MicroApps-AppName');
+    expect(requestResponse.headers['x-microapps-appname'][0].value).toBe(AppName.toLowerCase());
+    expect(requestResponse.headers).toHaveProperty('x-microapps-semver');
+    expect(requestResponse.headers['x-microapps-semver'][0].key).toBe('X-MicroApps-SemVer');
+    expect(requestResponse.headers['x-microapps-semver'][0].value).toBe(SemVer);
+    expect(requestResponse.headers).toHaveProperty('host');
+    expect(requestResponse.headers.host).toHaveLength(1);
+    expect(requestResponse.headers.host[0].key).toBe('Host');
+    expect(requestResponse.headers.host[0].value).toBe('abc123.lambda-url.us-east-1.on.aws');
+    expect(requestResponse).toHaveProperty('origin');
+    expect(requestResponse.origin).toHaveProperty('custom');
+    expect(requestResponse?.origin?.custom).toHaveProperty('domainName');
+    expect(requestResponse?.origin?.custom?.domainName).toBe('abc123.lambda-url.us-east-1.on.aws');
+  });
+
   describe('/_next/data/ requests with no basePath', () => {
     const testCases = [
-      [
-        {
-          AppName: 'BatDirectNoBaseNextData',
-          LambdaURL1: 'https://abc123.lambda-url.us-east-1.on.aws/',
-          SemVer1: '1.2.1-beta.1',
-          LambdaURL2: 'https://abc1234567.lambda-url.us-east-1.on.aws/',
-          SemVer2: '1.2.1-beta.2',
-          SuffixPath: 'batdirectnobasenextdata/route.json',
-        },
-      ],
-      [
-        {
-          AppName: 'BatDirectNoBaseNextDataRootRoute',
-          LambdaURL1: 'https://abc124.lambda-url.us-east-1.on.aws/',
-          SemVer1: '1.2.1-beta.3',
-          LambdaURL2: 'https://abc1234568.lambda-url.us-east-1.on.aws/',
-          SemVer2: '1.2.1-beta.4',
-          SuffixPath: 'batdirectnobasenextdatarootroute.json',
-        },
-      ],
+      {
+        AppName: 'BatDirectNoBaseNextData',
+        LambdaURL1: 'https://abc123.lambda-url.us-east-1.on.aws/',
+        SemVer1: '1.2.1-beta.1',
+        LambdaURL2: 'https://abc1234567.lambda-url.us-east-1.on.aws/',
+        SemVer2: '1.2.1-beta.2',
+        Locales: [],
+        RawPath: '/_next/data/1.2.1-beta.2/batdirectnobasenextdata/route.json',
+        SuffixPath: 'batdirectnobasenextdata/route.json',
+      },
+      {
+        AppName: 'BatDirectNoBaseNextDataRootRoute',
+        LambdaURL1: 'https://abc124.lambda-url.us-east-1.on.aws/',
+        SemVer1: '1.2.1-beta.3',
+        LambdaURL2: 'https://abc1234568.lambda-url.us-east-1.on.aws/',
+        SemVer2: '1.2.1-beta.4',
+        Locales: [],
+        RawPath: '/_next/data/1.2.1-beta.4/batdirectnobasenextdatarootroute.json',
+        SuffixPath: 'batdirectnobasenextdatarootroute.json',
+      },
+      {
+        AppName: 'BatDirectNoBaseNextDataRootRouteLocale',
+        LambdaURL1: 'https://abc124.lambda-url.us-east-1.on.aws/',
+        SemVer1: '1.2.1-beta.3',
+        LambdaURL2: 'https://abc1234568.lambda-url.us-east-1.on.aws/',
+        SemVer2: '1.2.1-beta.4',
+        Locales: ['en', 'sv'],
+        RawPath: '/_next/data/1.2.1-beta.4/sv/batdirectnobasenextdatarootroutelocale.json',
+        SuffixPath: 'batdirectnobasenextdatarootroutelocale.json',
+      },
     ];
 
     it.each(testCases)(
-      'should route `direct` /_next/data/[${SemVer2}]/[${AppName}] request to [${AppName}] when it exists but ${SemVer1} is the default',
-      async ({ AppName, LambdaURL1, SemVer1, LambdaURL2, SemVer2, SuffixPath }) => {
+      'should route `direct` /_next/data/[$SemVer2]/[$AppName] request to [$AppName] when it exists but $SemVer1 is the default',
+      async ({
+        AppName,
+        LambdaURL1,
+        SemVer1,
+        LambdaURL2,
+        SemVer2,
+        RawPath,
+        Locales,
+        SuffixPath,
+      }) => {
         theConfig.replaceHostHeader = true;
+        theConfig.locales = Locales;
 
         const app = new Application({
           AppName,
@@ -379,7 +497,7 @@ describe('edge-to-origin - routing - without prefix', () => {
                     method: 'GET',
                     querystring: '',
                     clientIp: '1.1.1.1',
-                    uri: `/_next/data/${SemVer2}/${SuffixPath}`,
+                    uri: `${RawPath}`,
                     origin: {
                       custom: {
                         customHeaders: {},
@@ -414,7 +532,7 @@ describe('edge-to-origin - routing - without prefix', () => {
         expect(requestResponse.headers).toHaveProperty('x-microapps-semver');
         expect(requestResponse.headers['x-microapps-semver'][0].key).toBe('X-MicroApps-SemVer');
         expect(requestResponse.headers['x-microapps-semver'][0].value).toBe(SemVer2);
-        expect(requestResponse.uri).toBe(`/_next/data/${SemVer2}/${SuffixPath}`);
+        expect(requestResponse.uri).toBe(`${RawPath}`);
         expect(requestResponse).toHaveProperty('origin');
         expect(requestResponse.origin).toHaveProperty('custom');
         expect(requestResponse?.origin?.custom).toHaveProperty('domainName');
diff --git a/packages/microapps-edge-to-origin/src/index.spec.ts b/packages/microapps-edge-to-origin/src/index.spec.ts
index 61da1572..9cbe82df 100644
--- a/packages/microapps-edge-to-origin/src/index.spec.ts
+++ b/packages/microapps-edge-to-origin/src/index.spec.ts
@@ -13,6 +13,7 @@ const theConfig: Writeable<IConfig> = {
   signingMode: 'sign',
   tableName: '',
   rootPathPrefix: '',
+  locales: [],
 };
 const origConfig = { ...theConfig };
 Object.defineProperty(Config, 'instance', {
@@ -42,248 +43,153 @@ describe('handler', () => {
     process.env.AWS_SESSION_TOKEN = 'session-token';
   });
 
-  it('overwrites host header when enabled', async () => {
-    theConfig.replaceHostHeader = true;
-
-    const cfRequest: lambda.CloudFrontRequest = {
-      clientIp: '1.1.1.1',
-      headers: {
-        accept: [
-          {
-            key: 'accept',
-            value:
-              'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
-          },
-        ],
-        host: [
-          {
-            key: 'host',
-            value: 'test.pwrdrvr.com',
-          },
-        ],
-        'x-forwarded-for': [
-          {
-            key: 'x-forwarded-for',
-            value: '1.1.1.1, 2.2.2.2',
-          },
-        ],
-      },
-      method: 'GET',
-      querystring: 'param1=value1&param2=value2',
-      uri: '/test/test.html',
-      origin: {
-        // @ts-expect-error not specifying full obj
-        custom: {
-          domainName: 'test-origin.pwrdrvr.com',
-        },
-      },
-    };
-
-    const signedRequest = await handler(
-      {
-        Records: [
-          {
-            cf: {
-              request: cfRequest,
-              config: {
-                distributionDomainName: 'test-edge.pwrdrvr.com',
-                distributionId: 'ABC123',
-                eventType: 'origin-request',
-                requestId: 'ABC456',
+  describe('host header', () => {
+    const testCases = [
+      { replaceHostHeader: true, expectedHost: 'test-origin.pwrdrvr.com' },
+      { replaceHostHeader: false, expectedHost: 'test.pwrdrvr.com' },
+    ];
+
+    it.each(testCases)(
+      'host header when replaceHostHeader is %p',
+      async ({ replaceHostHeader, expectedHost }) => {
+        theConfig.replaceHostHeader = replaceHostHeader;
+
+        const cfRequest: lambda.CloudFrontRequest = {
+          clientIp: '1.1.1.1',
+          headers: {
+            accept: [
+              {
+                key: 'accept',
+                value:
+                  'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
               },
-            },
-          },
-        ],
-      },
-      {} as lambda.Context,
-      () => undefined,
-    );
-
-    expect(signedRequest).toBeDefined();
-    expect(signedRequest!.headers).toBeDefined();
-    expect(signedRequest!.headers!['host'][0].value).toEqual('test-origin.pwrdrvr.com');
-  });
-
-  it('does not overwrite host header when disabled', async () => {
-    theConfig.replaceHostHeader = false;
-
-    const cfRequest: lambda.CloudFrontRequest = {
-      clientIp: '1.1.1.1',
-      headers: {
-        accept: [
-          {
-            key: 'accept',
-            value:
-              'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
-          },
-        ],
-        host: [
-          {
-            key: 'host',
-            value: 'test.pwrdrvr.com',
+            ],
+            host: [
+              {
+                key: 'host',
+                value: 'test.pwrdrvr.com',
+              },
+            ],
+            'x-forwarded-for': [
+              {
+                key: 'x-forwarded-for',
+                value: '1.1.1.1, 2.2.2.2',
+              },
+            ],
           },
-        ],
-        'x-forwarded-for': [
-          {
-            key: 'x-forwarded-for',
-            value: '1.1.1.1, 2.2.2.2',
+          method: 'GET',
+          querystring: 'param1=value1&param2=value2',
+          uri: '/test/test.html',
+          origin: {
+            // @ts-expect-error not specifying full obj
+            custom: {
+              domainName: 'test-origin.pwrdrvr.com',
+            },
           },
-        ],
-      },
-      method: 'GET',
-      querystring: 'param1=value1&param2=value2',
-      uri: '/test/test.html',
-      origin: {
-        // @ts-expect-error not specifying full obj
-        custom: {
-          domainName: 'test-origin.pwrdrvr.com',
-        },
-      },
-    };
+        };
 
-    const signedRequest = await handler(
-      {
-        Records: [
+        const signedRequest = await handler(
           {
-            cf: {
-              request: cfRequest,
-              config: {
-                distributionDomainName: 'test-edge.pwrdrvr.com',
-                distributionId: 'ABC123',
-                eventType: 'origin-request',
-                requestId: 'ABC456',
+            Records: [
+              {
+                cf: {
+                  request: cfRequest,
+                  config: {
+                    distributionDomainName: 'test-edge.pwrdrvr.com',
+                    distributionId: 'ABC123',
+                    eventType: 'origin-request',
+                    requestId: 'ABC456',
+                  },
+                },
               },
-            },
+            ],
           },
-        ],
-      },
-      {} as lambda.Context,
-      () => undefined,
-    );
+          {} as lambda.Context,
+          () => undefined,
+        );
 
-    expect(signedRequest).toBeDefined();
-    expect(signedRequest!.headers).toBeDefined();
-    expect(signedRequest!.headers!['host'][0].value).toEqual('test.pwrdrvr.com');
-  });
-
-  it('adds x-forwarded-host when enabled', async () => {
-    theConfig.addXForwardedHostHeader = true;
-
-    const cfRequest: lambda.CloudFrontRequest = {
-      clientIp: '1.1.1.1',
-      headers: {
-        accept: [
-          {
-            key: 'accept',
-            value:
-              'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
-          },
-        ],
-        host: [
-          {
-            key: 'host',
-            value: 'test.pwrdrvr.com',
-          },
-        ],
-        'x-forwarded-for': [
-          {
-            key: 'x-forwarded-for',
-            value: '1.1.1.1, 2.2.2.2',
-          },
-        ],
-      },
-      method: 'GET',
-      origin: {
-        // @ts-expect-error do not need full object for this test
-        custom: { domainName: 'random-but-account-specific-name.lambda-url.us-east-2.on.aws' },
-      },
-      querystring: 'param1=value1&param2=value2',
-      uri: '/test/test.html',
-    };
+        expect(signedRequest).toBeDefined();
+        expect(signedRequest!.headers).toBeDefined();
 
-    const signedRequest = await handler(
-      {
-        Records: [
-          {
-            cf: {
-              request: cfRequest,
-              config: {
-                distributionDomainName: 'test-edge.pwrdrvr.com',
-                distributionId: 'ABC123',
-                eventType: 'origin-request',
-                requestId: 'ABC456',
-              },
-            },
-          },
-        ],
+        expect(signedRequest!.headers!['host'][0].value).toEqual(expectedHost);
       },
-      {} as lambda.Context,
-      () => undefined,
     );
-
-    expect(signedRequest).toBeDefined();
-    expect(signedRequest!.headers).toBeDefined();
-    expect(signedRequest!.headers!['x-forwarded-host'][0].value).toEqual('test.pwrdrvr.com');
   });
 
-  it('does not add x-forwarded-host when disabled', async () => {
-    theConfig.addXForwardedHostHeader = false;
-
-    const cfRequest: lambda.CloudFrontRequest = {
-      clientIp: '1.1.1.1',
-      headers: {
-        accept: [
-          {
-            key: 'accept',
-            value:
-              'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
-          },
-        ],
-        host: [
-          {
-            key: 'host',
-            value: 'test.pwrdrvr.com',
+  describe('x-forwarded-host header', () => {
+    const testCases = [
+      { addXForwardedHostHeader: true, expectedHost: 'test.pwrdrvr.com' },
+      { addXForwardedHostHeader: false, expectedHost: undefined },
+    ];
+
+    it.each(testCases)(
+      'host header when replaceHostHeader is %p',
+      async ({ addXForwardedHostHeader, expectedHost }) => {
+        theConfig.addXForwardedHostHeader = addXForwardedHostHeader;
+
+        const cfRequest: lambda.CloudFrontRequest = {
+          clientIp: '1.1.1.1',
+          headers: {
+            accept: [
+              {
+                key: 'accept',
+                value:
+                  'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
+              },
+            ],
+            host: [
+              {
+                key: 'host',
+                value: 'test.pwrdrvr.com',
+              },
+            ],
+            'x-forwarded-for': [
+              {
+                key: 'x-forwarded-for',
+                value: '1.1.1.1, 2.2.2.2',
+              },
+            ],
           },
-        ],
-        'x-forwarded-for': [
-          {
-            key: 'x-forwarded-for',
-            value: '1.1.1.1, 2.2.2.2',
+          method: 'GET',
+          querystring: 'param1=value1&param2=value2',
+          uri: '/test/test.html',
+          origin: {
+            // @ts-expect-error not specifying full obj
+            custom: {
+              domainName: 'test-origin.pwrdrvr.com',
+            },
           },
-        ],
-      },
-      method: 'GET',
-      querystring: 'param1=value1&param2=value2',
-      uri: '/test/test.html',
-      origin: {
-        // @ts-expect-error do not need full object for this test
-        custom: { domainName: 'random-but-account-specific-name.lambda-url.us-east-2.on.aws' },
-      },
-    };
+        };
 
-    const signedRequest = await handler(
-      {
-        Records: [
+        const signedRequest = await handler(
           {
-            cf: {
-              request: cfRequest,
-              config: {
-                distributionDomainName: 'test-edge.pwrdrvr.com',
-                distributionId: 'ABC123',
-                eventType: 'origin-request',
-                requestId: 'ABC456',
+            Records: [
+              {
+                cf: {
+                  request: cfRequest,
+                  config: {
+                    distributionDomainName: 'test-edge.pwrdrvr.com',
+                    distributionId: 'ABC123',
+                    eventType: 'origin-request',
+                    requestId: 'ABC456',
+                  },
+                },
               },
-            },
+            ],
           },
-        ],
+          {} as lambda.Context,
+          () => undefined,
+        );
+
+        expect(signedRequest).toBeDefined();
+        expect(signedRequest!.headers).toBeDefined();
+        if (addXForwardedHostHeader) {
+          expect(signedRequest!.headers!['x-forwarded-host'][0].value).toEqual(expectedHost);
+        } else {
+          expect(signedRequest!.headers!['x-forwarded-host']).toBeUndefined();
+        }
       },
-      {} as lambda.Context,
-      () => undefined,
     );
-
-    expect(signedRequest).toBeDefined();
-    expect(signedRequest!.headers).toBeDefined();
-    expect(signedRequest!.headers!['x-forwarded-host']).toBeUndefined();
   });
 
   it('does not sign request when disabled', async () => {
diff --git a/packages/microapps-edge-to-origin/src/index.ts b/packages/microapps-edge-to-origin/src/index.ts
index 7f568758..913d24ac 100644
--- a/packages/microapps-edge-to-origin/src/index.ts
+++ b/packages/microapps-edge-to-origin/src/index.ts
@@ -123,6 +123,7 @@ export const handler: lambda.CloudFrontRequestHandler = async (
 
       const route = await GetRoute({
         dbManager,
+        locales: config.locales,
         rawPath: event.Records[0].cf.request.uri,
         queryStringParameters: url.searchParams,
         normalizedPathPrefix,
diff --git a/packages/microapps-router-lib/package.json b/packages/microapps-router-lib/package.json
index 32ab6527..6a0c892f 100644
--- a/packages/microapps-router-lib/package.json
+++ b/packages/microapps-router-lib/package.json
@@ -23,16 +23,18 @@
     "fs-extra": "^9.0.0",
     "lambda-log": "^3.0.0",
     "source-map-support": "^0.5.0",
-    "tslib": "^2.0.0"
+    "tslib": "^2.1.0"
   },
   "devDependencies": {
     "@aws-sdk/client-dynamodb": "3.78.0",
     "@types/aws-lambda": "8.10.110",
     "@types/lambda-log": "^2.2.0",
+    "@types/mock-fs": "4.13.1",
     "@types/node": "16.9.2",
     "@types/source-map-support": "0.5.6",
     "fs-extra": "^9.1.0",
     "lambda-log": "^3.1.0",
+    "mock-fs": "5.2.0",
     "source-map-support": "0.5.21",
     "tslib": "^2.1.0"
   },
diff --git a/packages/microapps-router-lib/src/get-app-info.spec.ts b/packages/microapps-router-lib/src/get-app-info.spec.ts
new file mode 100644
index 00000000..a3298db5
--- /dev/null
+++ b/packages/microapps-router-lib/src/get-app-info.spec.ts
@@ -0,0 +1,77 @@
+import { DBManager, Rules } from '@pwrdrvr/microapps-datalib';
+import { AppVersionCache } from './app-cache';
+import { GetAppInfo } from './get-app-info';
+
+jest.mock('@pwrdrvr/microapps-datalib');
+
+describe('GetAppInfo', () => {
+  const dbManager = {} as DBManager;
+  const getAppVersionCacheSpy = jest.spyOn(AppVersionCache, 'GetInstance');
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  afterEach(() => {
+    getAppVersionCacheSpy.mockReset();
+  });
+
+  const testCases = [
+    {
+      caseName: 'returns the correct app name when it exists',
+      appName: 'testApp',
+      mockRules: {
+        AppName: 'testapp',
+        Version: 1,
+        RuleSet: {
+          default: {
+            AttributeName: 'default',
+            AttributeValue: '',
+            SemVer: '1.0.0',
+          },
+        },
+      } as unknown as Rules,
+      expected: 'testApp',
+    },
+    {
+      caseName:
+        'returns undefined when the app name does not exist and there is no [root] catch-all app',
+      appName: 'nonexistent',
+      mockRules: undefined,
+      expected: undefined,
+    },
+    {
+      caseName:
+        'returns [root] when the app name does not exist but there is a [root] catch-all app',
+      appName: 'nonexistent',
+      mockRules: {
+        AppName: '[root]',
+        RuleSet: {
+          default: {
+            AttributeName: 'default',
+            AttributeValue: '',
+            SemVer: '9.0.0',
+          },
+        } as unknown as Rules,
+      },
+      expected: '[root]',
+    },
+  ];
+
+  it.each(testCases)('$caseName', async ({ caseName, appName, mockRules, expected }) => {
+    getAppVersionCacheSpy.mockImplementation(() => {
+      return {
+        // eslint-disable-next-line @typescript-eslint/require-await
+        GetRules: async ({ key }: { key: { AppName: string } }) => {
+          if (key.AppName === appName || key.AppName === '[root]') {
+            return mockRules;
+          }
+          return undefined;
+        },
+      } as unknown as AppVersionCache;
+    });
+
+    const result = await GetAppInfo({ dbManager, appName });
+    expect(result).toEqual(expected);
+  });
+});
diff --git a/packages/microapps-router-lib/src/get-app-info.ts b/packages/microapps-router-lib/src/get-app-info.ts
new file mode 100644
index 00000000..688df15a
--- /dev/null
+++ b/packages/microapps-router-lib/src/get-app-info.ts
@@ -0,0 +1,31 @@
+import { DBManager, Rules } from '@pwrdrvr/microapps-datalib';
+import { AppVersionCache } from './app-cache';
+
+/**
+ * Determine if we have an appname or a catch all app
+ */
+
+export async function GetAppInfo(opts: {
+  dbManager: DBManager;
+  appName: string;
+}): Promise<string | undefined> {
+  const { dbManager, appName } = opts;
+
+  let rules: Rules | undefined;
+
+  const appVersionCache = AppVersionCache.GetInstance({ dbManager });
+
+  // Check if we got a matching app name
+  rules = await appVersionCache.GetRules({ key: { AppName: appName } });
+  if (rules && rules.AppName === appName.toLowerCase()) {
+    return appName;
+  }
+
+  // Check if we have a `[root]` app that is a catch all
+  rules = await appVersionCache.GetRules({ key: { AppName: '[root]' } });
+  if (rules && rules.AppName === '[root]') {
+    return '[root]';
+  }
+
+  return undefined;
+}
diff --git a/packages/microapps-router-lib/src/index.prefix.spec.ts b/packages/microapps-router-lib/src/get-route.prefix.spec.ts
similarity index 100%
rename from packages/microapps-router-lib/src/index.prefix.spec.ts
rename to packages/microapps-router-lib/src/get-route.prefix.spec.ts
diff --git a/packages/microapps-router-lib/src/index.spec.ts b/packages/microapps-router-lib/src/get-route.spec.ts
similarity index 90%
rename from packages/microapps-router-lib/src/index.spec.ts
rename to packages/microapps-router-lib/src/get-route.spec.ts
index 7cdfe27d..4f6f8ab6 100644
--- a/packages/microapps-router-lib/src/index.spec.ts
+++ b/packages/microapps-router-lib/src/get-route.spec.ts
@@ -372,24 +372,47 @@ describe('router - without prefix', () => {
 
     describe('/_next/data/ no-basePath', () => {
       const testCases = [
-        [
-          'DirectBatNextData',
-          '3.2.1-beta3',
-          'directbatnextdata/demo.json',
-          'https://abc123.lambda-url.us-east-1.on.aws',
-        ],
-        [
-          'AnotherAppName',
-          '1.0.0',
-          'anotherappname.json',
-          'https://abc124.lambda-url.us-east-1.on.aws',
-        ],
+        {
+          AppName: 'DirectBatNextData',
+          SemVer: '3.2.1-beta3',
+          Locales: ['en'],
+          LambdaFunctionURL: 'https://abc123.lambda-url.us-east-1.on.aws',
+          RawPath: '/_next/data/3.2.1-beta3/directbatnextdata/demo.json',
+        },
+        {
+          AppName: 'AnotherAppName',
+          SemVer: '1.0.0',
+          Locales: ['en'],
+          LambdaFunctionURL: 'https://abc124.lambda-url.us-east-1.on.aws',
+          RawPath: '/_next/data/1.0.0/anotherappname.json',
+        },
+        {
+          AppName: 'AppWithLocales',
+          SemVer: '1.0.0',
+          Locales: ['en', 'sv'],
+          LambdaFunctionURL: 'https://abc125.lambda-url.us-east-1.on.aws',
+          RawPath: '/_next/data/1.0.0/sv/appwithlocales.json',
+        },
+        {
+          AppName: 'ComplicatedLocales',
+          SemVer: '1.0.0',
+          Locales: ['en', 'en-US', 'sv', 'zh-Hant'],
+          LambdaFunctionURL: 'https://abc125.lambda-url.us-east-1.on.aws',
+          RawPath: '/_next/data/1.0.0/en-US/complicatedlocales.json',
+        },
+        {
+          AppName: 'ComplicatedLocales',
+          SemVer: '1.0.0',
+          Locales: ['en', 'en-US', 'sv', 'zh-Hant'],
+          LambdaFunctionURL: 'https://abc125.lambda-url.us-east-1.on.aws',
+          RawPath: '/_next/data/1.0.0/en-US/complicatedlocales/index.json',
+        },
         // Add more test cases as needed
       ];
 
       it.each(testCases)(
-        'should serve direct app with /_next/data/%s/%s/%s route',
-        async (AppName, SemVer, RoutePathSuffix, LambdaFunctionURL) => {
+        'should serve direct app with $RawPath route',
+        async ({ AppName, SemVer, LambdaFunctionURL, Locales = [], RawPath }) => {
           const app = new Application({
             AppName,
             DisplayName: 'Bat App',
@@ -419,7 +442,8 @@ describe('router - without prefix', () => {
           // Call the handler
           const response = await GetRoute({
             dbManager,
-            rawPath: `/_next/data/${SemVer}/${RoutePathSuffix}`,
+            locales: Locales,
+            rawPath: RawPath,
           });
 
           expect(response).toBeDefined();
diff --git a/packages/microapps-router-lib/src/get-route.ts b/packages/microapps-router-lib/src/get-route.ts
new file mode 100644
index 00000000..02c3b4e7
--- /dev/null
+++ b/packages/microapps-router-lib/src/get-route.ts
@@ -0,0 +1,251 @@
+import Log from './lib/log';
+import { GetAppInfo } from './get-app-info';
+import { RouteApp } from './route-app';
+import { RedirectToDefaultFile } from './redirect-default-file';
+import { DBManager } from '@pwrdrvr/microapps-datalib';
+
+const log = Log.Instance;
+
+export interface IGetRouteResult {
+  /**
+   * HTTP status code for immediate response, immediate redirect, and errors
+   */
+  readonly statusCode?: number;
+
+  /**
+   * Error message for errors
+   */
+  readonly errorMessage?: string;
+
+  /**
+   * Location to redirect to
+   */
+  readonly redirectLocation?: string;
+
+  /**
+   * Optional headers for immediate response, immediate redirect, and errors
+   */
+  readonly headers?: Record<string, string>;
+
+  /**
+   *
+   *
+   * @example /myapp/1.0.0/index.html
+   * @example /myapp/1.0.1
+   * @example /myapp/1.0.2/some/path?query=string
+   */
+  readonly iFrameAppVersionPath?: string;
+
+  /**
+   * Name of the app if resolved
+   */
+  readonly appName?: string;
+
+  /**
+   * Version of the app if resolved
+   */
+  readonly semVer?: string;
+
+  /**
+   * Type of the app
+   */
+  readonly type?: 'apigwy' | 'lambda-url' | 'url' | 'static';
+
+  /**
+   * Startup type of the app (indirect with iframe or direct)
+   */
+  readonly startupType?: 'iframe' | 'direct';
+
+  /**
+   * URL to the app if resolved
+   */
+  readonly url?: string;
+}
+
+export interface IGetRouteEvent {
+  readonly dbManager: DBManager;
+
+  /**
+   * rawPath from the Lambda event
+   */
+  readonly rawPath: string;
+
+  /**
+   * List of locale prefixes after the normalizedPathPrefix
+   *
+   * @default []
+   */
+  readonly locales?: string[];
+
+  /**
+   * Configured prefix of the deployment, must start with a / and not end with a /
+   */
+  readonly normalizedPathPrefix?: string;
+
+  /**
+   * Query string params, if any
+   * Checked for `appver=1.2.3` to override the app version
+   */
+  readonly queryStringParameters?: URLSearchParams;
+}
+
+/**
+ * Get information about immediate redirect, immediate response,
+ * or which host to route the request to.
+ *
+ * @param event
+ *
+ * @returns IGetRouteResult
+ */
+
+export async function GetRoute(event: IGetRouteEvent): Promise<IGetRouteResult> {
+  const { dbManager, normalizedPathPrefix = '', queryStringParameters, locales = [] } = event;
+
+  try {
+    if (!!normalizedPathPrefix && !event.rawPath.startsWith(normalizedPathPrefix)) {
+      // The prefix is required if configured, if missing we cannot serve this app
+      return { statusCode: 404, errorMessage: 'Request not routable' };
+    }
+
+    const pathAfterPrefix =
+      normalizedPathPrefix && event.rawPath.startsWith(normalizedPathPrefix)
+        ? event.rawPath.slice(normalizedPathPrefix.length - 1)
+        : event.rawPath;
+
+    const pathAfterPrefixAndLocale = locales.reduce((path, locale) => {
+      if (path.startsWith(`/${locale}/`)) {
+        return path.slice(locale.length + 1);
+      }
+      return path;
+    }, pathAfterPrefix);
+
+    // /someapp will split into length 2 with ["", "someapp"] as results
+    // /someapp/somepath will split into length 3 with ["", "someapp", "somepath"] as results
+    // /someapp/somepath/ will split into length 3 with ["", "someapp", "somepath", ""] as results
+    // /someapp/somepath/somefile.foo will split into length 4 with ["", "someapp", "somepath", "somefile.foo", ""] as results
+    const partsAfterPrefixAndLocale = pathAfterPrefixAndLocale.split('/');
+
+    // Handle ${prefix}/_next/data/${semver}[/${locale}]/${appname}/route
+    let appName: string | undefined;
+    if (
+      partsAfterPrefixAndLocale.length >= 4 &&
+      partsAfterPrefixAndLocale[1] === '_next' &&
+      partsAfterPrefixAndLocale[2] === 'data'
+    ) {
+      // Remove locale if present after SemVer
+      const localeIsPresent =
+        partsAfterPrefixAndLocale.length >= 5 &&
+        locales.some((locale) => partsAfterPrefixAndLocale[4] === locale);
+      const possibleAppNamePart = localeIsPresent
+        ? partsAfterPrefixAndLocale[5]
+        : partsAfterPrefixAndLocale[4];
+
+      // if partsAfterPrefix[4] has .json suffix, strip it
+      const possibleAppName = possibleAppNamePart.endsWith('.json')
+        ? possibleAppNamePart.slice(0, possibleAppNamePart.length - 5)
+        : possibleAppNamePart;
+
+      appName = await GetAppInfo({
+        dbManager,
+        appName: possibleAppName,
+      });
+    }
+
+    if (!appName) {
+      appName = await GetAppInfo({
+        dbManager,
+        appName: partsAfterPrefixAndLocale.length >= 2 ? partsAfterPrefixAndLocale[1] : '[root]',
+      });
+    }
+
+    if (!appName) {
+      return { statusCode: 404, errorMessage: 'App not found' };
+    }
+
+    const isRootApp = appName === '[root]';
+    const appNameOrRootTrailingSlash = isRootApp ? '' : `${appName}/`;
+
+    // Strip the appName from the start of the path, if there was one
+    const pathAfterAppName = isRootApp
+      ? pathAfterPrefixAndLocale
+      : pathAfterPrefixAndLocale.slice(appName.length + 1);
+    const partsAfterAppName = pathAfterAppName.split('/');
+
+    // Pass any parts after the appName/Version to the route handler
+    let additionalParts = '';
+    if (partsAfterAppName.length >= 2 && partsAfterAppName[1] !== '') {
+      additionalParts = partsAfterAppName.slice(1).join('/');
+    }
+
+    // Route an app and version (only) to include the defaultFile
+    // If the second part is not a version that exists, fall through to
+    // routing the app and glomming the rest of the path on to the end
+    if (
+      partsAfterAppName.length === 2 ||
+      (partsAfterAppName.length === 3 && !partsAfterAppName[2])
+    ) {
+      //   / semVer /
+      // ^   ^^^^^^   ^
+      // 0        1   2
+      // This is an app and a version only
+      // If the request got here it's likely a static app that has no
+      // Lambda function (thus the API Gateway route fell through to the Router)
+      const response = await RedirectToDefaultFile({
+        dbManager,
+        appName,
+        normalizedPathPrefix,
+        semVer: partsAfterAppName[1],
+        appNameOrRootTrailingSlash,
+      });
+      if (response) {
+        return response;
+      }
+    }
+
+    // Check for a version in the path
+    // Examples
+    //  / semVer / somepath
+    //  / _next / data / semVer / somepath
+    // Get the version afer `/_next/data/` from partsAfterPrefix
+    const possibleSemVerPathNextDataBasePath =
+      partsAfterAppName.length >= 4 ? partsAfterAppName[3] : '';
+    const possibleSemVerPathNextData =
+      partsAfterPrefixAndLocale.length >= 4 &&
+      partsAfterPrefixAndLocale[1] === '_next' &&
+      partsAfterPrefixAndLocale[2] == 'data'
+        ? partsAfterPrefixAndLocale[3]
+        : possibleSemVerPathNextDataBasePath;
+
+    const possibleSemVerPathAfterApp = partsAfterAppName.length >= 2 ? partsAfterAppName[1] : '';
+
+    //  (/ something)?
+    // ^  ^^^^^^^^^^^^
+    // 0             1
+    // Got at least an application name, try to route it
+    const response = await RouteApp({
+      dbManager,
+      normalizedPathPrefix,
+      event,
+      appName,
+      possibleSemVerPathNextData,
+      possibleSemVerPathAfterApp,
+      possibleSemVerQuery: queryStringParameters?.get('appver') || '',
+      additionalParts,
+      appNameOrRootTrailingSlash,
+    });
+    if (response) {
+      return response;
+    }
+
+    return {
+      statusCode: 599,
+      errorMessage: `Router - Could not route: ${event.rawPath}, no matching route`,
+    };
+  } catch (error: any) {
+    log.error('unexpected exception - returning 599', { statusCode: 599, error });
+    return {
+      statusCode: 599,
+      errorMessage: `Router - Could not route: ${event.rawPath}, ${error.message}`,
+    };
+  }
+}
diff --git a/packages/microapps-router-lib/src/index.ts b/packages/microapps-router-lib/src/index.ts
index 0d013552..9a210fa0 100644
--- a/packages/microapps-router-lib/src/index.ts
+++ b/packages/microapps-router-lib/src/index.ts
@@ -1,569 +1,5 @@
-import 'source-map-support/register';
-import path from 'path';
-import { pathExistsSync, readFileSync } from 'fs-extra';
-import { DBManager, Rules, Version } from '@pwrdrvr/microapps-datalib';
-import Log from './lib/log';
-import { AppVersionCache } from './app-cache';
-
-const log = Log.Instance;
-
-export { AppVersionCache };
-
-/**
- * Find and load the appFrame file
- * @returns
- */
-export function loadAppFrame({ basePath = '.' }: { basePath?: string }): string {
-  const paths = [
-    basePath,
-    path.join(basePath, '..'),
-    path.join(basePath, 'templates'),
-    basePath,
-    '/opt',
-    '/opt/templates',
-  ];
-
-  for (const pathRoot of paths) {
-    const fullPath = path.join(pathRoot, 'appFrame.html');
-    try {
-      if (pathExistsSync(fullPath)) {
-        log.info('found html file', { fullPath });
-        return readFileSync(fullPath, 'utf-8');
-      }
-    } catch {
-      // Don't care - we get here if stat throws because the file does not exist
-    }
-  }
-
-  log.error('appFrame.html not found');
-  throw new Error('appFrame.html not found');
-}
-
-/**
- * Ensure that the path starts with a / and does not end with a /
- *
- * @param pathPrefix
- * @returns
- */
-export function normalizePathPrefix(pathPrefix: string): string {
-  let normalizedPathPrefix = pathPrefix;
-  if (normalizedPathPrefix !== '' && !normalizedPathPrefix.startsWith('/')) {
-    normalizedPathPrefix = '/' + pathPrefix;
-  }
-  if (normalizedPathPrefix.endsWith('/')) {
-    normalizedPathPrefix.substring(0, normalizedPathPrefix.length - 1);
-  }
-
-  return normalizedPathPrefix;
-}
-
-export interface IGetRouteResult {
-  /**
-   * HTTP status code for immediate response, immediate redirect, and errors
-   */
-  readonly statusCode?: number;
-
-  /**
-   * Error message for errors
-   */
-  readonly errorMessage?: string;
-
-  /**
-   * Location to redirect to
-   */
-  readonly redirectLocation?: string;
-
-  /**
-   * Optional headers for immediate response, immediate redirect, and errors
-   */
-  readonly headers?: Record<string, string>;
-
-  /**
-   *
-   *
-   * @example /myapp/1.0.0/index.html
-   * @example /myapp/1.0.1
-   * @example /myapp/1.0.2/some/path?query=string
-   */
-  readonly iFrameAppVersionPath?: string;
-
-  /**
-   * Name of the app if resolved
-   */
-  readonly appName?: string;
-
-  /**
-   * Version of the app if resolved
-   */
-  readonly semVer?: string;
-
-  /**
-   * Type of the app
-   */
-  readonly type?: 'apigwy' | 'lambda-url' | 'url' | 'static';
-
-  /**
-   * Startup type of the app (indirect with iframe or direct)
-   */
-  readonly startupType?: 'iframe' | 'direct';
-
-  /**
-   * URL to the app if resolved
-   */
-  readonly url?: string;
-}
-
-export interface IGetRouteEvent {
-  readonly dbManager: DBManager;
-
-  /**
-   * rawPath from the Lambda event
-   */
-  readonly rawPath: string;
-
-  /**
-   * Configured prefix of the deployment, must start with a / and not end with a /
-   */
-  readonly normalizedPathPrefix?: string;
-
-  /**
-   * Query string params, if any
-   * Checked for `appver=1.2.3` to override the app version
-   */
-  readonly queryStringParameters?: URLSearchParams;
-}
-
-/**
- * Get information about immediate redirect, immediate response,
- * or which host to route the request to.
- *
- * @param event
- *
- * @returns IGetRouteResult
- */
-export async function GetRoute(event: IGetRouteEvent): Promise<IGetRouteResult> {
-  const { dbManager, normalizedPathPrefix = '', queryStringParameters } = event;
-
-  try {
-    if (normalizedPathPrefix && !event.rawPath.startsWith(normalizedPathPrefix)) {
-      // The prefix is required if configured, if missing we cannot serve this app
-      return { statusCode: 404, errorMessage: 'Request not routable' };
-    }
-
-    const pathAfterPrefix =
-      normalizedPathPrefix && event.rawPath.startsWith(normalizedPathPrefix)
-        ? event.rawPath.slice(normalizedPathPrefix.length - 1)
-        : event.rawPath;
-
-    // /someapp will split into length 2 with ["", "someapp"] as results
-    // /someapp/somepath will split into length 3 with ["", "someapp", "somepath"] as results
-    // /someapp/somepath/ will split into length 3 with ["", "someapp", "somepath", ""] as results
-    // /someapp/somepath/somefile.foo will split into length 4 with ["", "someapp", "somepath", "somefile.foo", ""] as results
-    const partsAfterPrefix = pathAfterPrefix.split('/');
-
-    // Handle ${prefix}/_next/data/${semver}/${appname}/route
-    let appName: string | undefined;
-    if (
-      partsAfterPrefix.length >= 4 &&
-      partsAfterPrefix[1] === '_next' &&
-      partsAfterPrefix[2] === 'data'
-    ) {
-      // if partsAfterPrefix[4] has .json suffix, strip it
-      const possibleAppName = partsAfterPrefix[4].endsWith('.json')
-        ? partsAfterPrefix[4].slice(0, partsAfterPrefix[4].length - 5)
-        : partsAfterPrefix[4];
-
-      appName = await GetAppInfo({
-        dbManager,
-        appName: possibleAppName,
-      });
-    }
-
-    if (!appName) {
-      appName = await GetAppInfo({
-        dbManager,
-        appName: partsAfterPrefix.length >= 2 ? partsAfterPrefix[1] : '[root]',
-      });
-    }
-
-    if (!appName) {
-      return { statusCode: 404, errorMessage: 'App not found' };
-    }
-
-    const isRootApp = appName === '[root]';
-    const appNameOrRootTrailingSlash = isRootApp ? '' : `${appName}/`;
-
-    // Strip the appName from the start of the path, if there was one
-    const pathAfterAppName = isRootApp
-      ? pathAfterPrefix
-      : pathAfterPrefix.slice(appName.length + 1);
-    const partsAfterAppName = pathAfterAppName.split('/');
-
-    // Pass any parts after the appName/Version to the route handler
-    let additionalParts = '';
-    if (partsAfterAppName.length >= 2 && partsAfterAppName[1] !== '') {
-      additionalParts = partsAfterAppName.slice(1).join('/');
-    }
-
-    // Route an app and version (only) to include the defaultFile
-    // If the second part is not a version that exists, fall through to
-    // routing the app and glomming the rest of the path on to the end
-    if (
-      partsAfterAppName.length === 2 ||
-      (partsAfterAppName.length === 3 && !partsAfterAppName[2])
-    ) {
-      //   / semVer /
-      // ^   ^^^^^^   ^
-      // 0        1   2
-      // This is an app and a version only
-      // If the request got here it's likely a static app that has no
-      // Lambda function (thus the API Gateway route fell through to the Router)
-      const response = await RedirectToDefaultFile({
-        dbManager,
-        appName,
-        normalizedPathPrefix,
-        semVer: partsAfterAppName[1],
-        appNameOrRootTrailingSlash,
-      });
-      if (response) {
-        return response;
-      }
-    }
-
-    // Check for a version in the path
-    // Examples
-    //  / semVer / somepath
-    //  / _next / data / semVer / somepath
-    // Get the version afer `/_next/data/` from partsAfterPrefix
-    const possibleSemVerPathNextDataBasePath =
-      partsAfterAppName.length >= 4 ? partsAfterAppName[3] : '';
-    const possibleSemVerPathNextData =
-      partsAfterPrefix.length >= 4 &&
-      partsAfterPrefix[1] === '_next' &&
-      partsAfterPrefix[2] == 'data'
-        ? partsAfterPrefix[3]
-        : possibleSemVerPathNextDataBasePath;
-
-    const possibleSemVerPathAfterApp = partsAfterAppName.length >= 2 ? partsAfterAppName[1] : '';
-
-    //  (/ something)?
-    // ^  ^^^^^^^^^^^^
-    // 0             1
-    // Got at least an application name, try to route it
-    const response = await RouteApp({
-      dbManager,
-      normalizedPathPrefix,
-      event,
-      appName,
-      possibleSemVerPathNextData,
-      possibleSemVerPathAfterApp,
-      possibleSemVerQuery: queryStringParameters?.get('appver') || '',
-      additionalParts,
-      appNameOrRootTrailingSlash,
-    });
-    if (response) {
-      return response;
-    }
-
-    return {
-      statusCode: 599,
-      errorMessage: `Router - Could not route: ${event.rawPath}, no matching route`,
-    };
-  } catch (error: any) {
-    log.error('unexpected exception - returning 599', { statusCode: 599, error });
-    return {
-      statusCode: 599,
-      errorMessage: `Router - Could not route: ${event.rawPath}, ${error.message}`,
-    };
-  }
-}
-
-/**
- * Determine if we have an appname or a catch all app
- */
-export async function GetAppInfo(opts: {
-  dbManager: DBManager;
-  appName: string;
-}): Promise<string | undefined> {
-  const { dbManager, appName } = opts;
-
-  let rules: Rules | undefined;
-
-  const appVersionCache = AppVersionCache.GetInstance({ dbManager });
-
-  // Check if we got a matching app name
-  rules = await appVersionCache.GetRules({ key: { AppName: appName } });
-  if (rules && rules.AppName === appName.toLowerCase()) {
-    return appName;
-  }
-
-  // Check if we have a `[root]` app that is a catch all
-  rules = await appVersionCache.GetRules({ key: { AppName: '[root]' } });
-  if (rules && rules.AppName === '[root]') {
-    return '[root]';
-  }
-
-  return undefined;
-}
-
-/**
- * Lookup the version of the app to run
- * @param event
- * @param response
- * @param appName
- * @param additionalParts
- * @param log
- * @returns
- */
-async function RouteApp(opts: {
-  dbManager: DBManager;
-  event: IGetRouteEvent;
-  appName: string;
-  possibleSemVerPathNextData?: string;
-  possibleSemVerPathAfterApp?: string;
-  possibleSemVerQuery?: string;
-  additionalParts: string;
-  normalizedPathPrefix?: string;
-  appNameOrRootTrailingSlash: string;
-}): Promise<IGetRouteResult> {
-  const {
-    dbManager,
-    event,
-    normalizedPathPrefix = '',
-    appName,
-    possibleSemVerPathNextData,
-    possibleSemVerPathAfterApp,
-    possibleSemVerQuery,
-    additionalParts,
-    appNameOrRootTrailingSlash,
-  } = opts;
-
-  let versionInfoToUse: Version | undefined;
-
-  const appVersionCache = AppVersionCache.GetInstance({ dbManager });
-
-  // Check if the semver placeholder is actually a defined version
-  const possibleSemVerPathAfterAppVersionInfo = possibleSemVerPathAfterApp
-    ? await appVersionCache.GetVersionInfo({
-        key: { AppName: appName, SemVer: possibleSemVerPathAfterApp },
-      })
-    : undefined;
-  const possibleSemVerPathNextDataVersionInfo = possibleSemVerPathNextData
-    ? await appVersionCache.GetVersionInfo({
-        key: { AppName: appName, SemVer: possibleSemVerPathNextData },
-      })
-    : undefined;
-  const possibleSemVerQueryVersionInfo = possibleSemVerQuery
-    ? await appVersionCache.GetVersionInfo({
-        key: { AppName: appName, SemVer: possibleSemVerQuery },
-      })
-    : undefined;
-
-  // If there is a version in the path, use it
-  const possibleSemVerPathVersionInfo =
-    possibleSemVerPathAfterAppVersionInfo || possibleSemVerPathNextDataVersionInfo;
-  if (possibleSemVerPathVersionInfo) {
-    // This is a version, and it's in the path already, route the request to it
-    // without creating iframe
-    return {
-      statusCode: 200,
-      appName,
-      semVer: possibleSemVerPathVersionInfo.SemVer,
-      ...(possibleSemVerPathVersionInfo?.URL ? { url: possibleSemVerPathVersionInfo?.URL } : {}),
-      ...(possibleSemVerPathVersionInfo?.Type
-        ? {
-            type:
-              possibleSemVerPathVersionInfo?.Type === 'lambda'
-                ? 'apigwy'
-                : possibleSemVerPathVersionInfo?.Type,
-          }
-        : {}),
-    };
-  } else if (possibleSemVerQueryVersionInfo) {
-    // We got a version for the query string, but it's not in the path,
-    // so fall back to normal routing (create an iframe or direct route)
-    versionInfoToUse = possibleSemVerQueryVersionInfo;
-  } else if (possibleSemVerQuery) {
-    // We got a version in the query string but it does not exist
-    // This needs to 404 as this is a very specific request for a specific version
-    log.error(`could not find app ${appName}, for path ${event.rawPath} - returning 404`, {
-      statusCode: 404,
-    });
-
-    return {
-      statusCode: 404,
-      errorMessage: `Router - Could not find app: ${event.rawPath}, ${appName}`,
-    };
-  } else {
-    //
-    // TODO: Get the incoming attributes of user
-    // For logged in users, these can be: department, product type,
-    //  employee, office, division, etc.
-    // For anonymous users this can be: geo region, language,
-    // browser, IP, CIDR, ASIN, etc.
-    //
-    // The Rules can be either a version or a distribution of versions,
-    // including default, for example:
-    //     80% to 1.1.0, 20% to default (1.0.3)
-    //
-
-    const rules = await appVersionCache.GetRules({ key: { AppName: appName } });
-    const defaultVersion = rules?.RuleSet.default?.SemVer;
-
-    if (defaultVersion == null) {
-      log.error(`could not find app ${appName}, for path ${event.rawPath} - returning 404`, {
-        statusCode: 404,
-      });
-
-      return {
-        statusCode: 404,
-        errorMessage: `Router - Could not find app: ${event.rawPath}, ${appName}`,
-      };
-    }
-
-    const defaultVersionInfo = await appVersionCache.GetVersionInfo({
-      key: { AppName: appName, SemVer: defaultVersion },
-    });
-
-    versionInfoToUse = defaultVersionInfo;
-  }
-
-  if (!versionInfoToUse) {
-    log.error(
-      `could not find version info for app ${appName}, for path ${event.rawPath} - returning 404`,
-      {
-        statusCode: 404,
-      },
-    );
-
-    return {
-      statusCode: 404,
-      errorMessage: `Router - Could not find version info for app: ${event.rawPath}, ${appName}`,
-    };
-  }
-
-  if (versionInfoToUse?.StartupType === 'iframe' || !versionInfoToUse?.StartupType) {
-    // Prepare the iframe contents
-    let appVersionPath: string;
-    if (
-      versionInfoToUse?.Type !== 'static' &&
-      (versionInfoToUse?.DefaultFile === undefined ||
-        versionInfoToUse?.DefaultFile === '' ||
-        additionalParts !== '')
-    ) {
-      // KLUDGE: We're going to take a missing default file to mean that the
-      // app type is Next.js (or similar) and that it wants no trailing slash after the version
-      // TODO: Move this to an attribute of the version
-      appVersionPath = `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${versionInfoToUse.SemVer}`;
-      if (additionalParts !== '') {
-        appVersionPath += `/${additionalParts}`;
-      }
-    } else {
-      // Linking to the file directly means this will be peeled off by the S3 route
-      // That means we won't have to proxy this from S3
-      appVersionPath = `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${versionInfoToUse.SemVer}/${versionInfoToUse.DefaultFile}`;
-    }
-
-    return {
-      statusCode: 200,
-      appName,
-      semVer: versionInfoToUse.SemVer,
-      startupType: 'iframe',
-      ...(versionInfoToUse?.URL ? { url: versionInfoToUse?.URL } : {}),
-      ...(versionInfoToUse?.Type
-        ? { type: versionInfoToUse?.Type === 'lambda' ? 'apigwy' : versionInfoToUse?.Type }
-        : {}),
-      iFrameAppVersionPath: appVersionPath,
-    };
-  } else {
-    // This is a direct app version, no iframe needed
-
-    if (versionInfoToUse?.Type === 'lambda') {
-      throw new Error('Invalid type for direct app version');
-    }
-    if (['apigwy', 'static'].includes(versionInfoToUse?.Type || '')) {
-      throw new Error('Invalid type for direct app version');
-    }
-
-    return {
-      statusCode: 200,
-      appName,
-      semVer: versionInfoToUse.SemVer,
-      startupType: 'direct',
-      ...(versionInfoToUse?.URL ? { url: versionInfoToUse?.URL } : {}),
-      ...(versionInfoToUse?.Type ? { type: versionInfoToUse?.Type } : {}),
-    };
-  }
-}
-
-/**
- * Redirect the request to app/x.y.z/? to app/x.y.z/{defaultFile}
- * @param response
- * @param normalizedPathPrefix
- * @param appName
- * @param semVer
- * @returns
- */
-async function RedirectToDefaultFile(opts: {
-  dbManager: DBManager;
-  normalizedPathPrefix?: string;
-  appName: string;
-  semVer: string;
-  appNameOrRootTrailingSlash: string;
-}): Promise<IGetRouteResult | undefined> {
-  const {
-    dbManager,
-    normalizedPathPrefix = '',
-    appName,
-    appNameOrRootTrailingSlash,
-    semVer,
-  } = opts;
-  let versionInfo: Version | undefined;
-
-  try {
-    // Get the cache
-    const appVersionCache = AppVersionCache.GetInstance({ dbManager });
-
-    versionInfo = await appVersionCache.GetVersionInfo({
-      key: { AppName: appName, SemVer: semVer },
-    });
-  } catch (error) {
-    log.info(
-      `LoadVersion threw for '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}' - falling through to app routing'`,
-      {
-        appName,
-        semVer,
-        error,
-      },
-    );
-    return undefined;
-  }
-
-  if (versionInfo === undefined) {
-    log.info(
-      `LoadVersion returned undefined for '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}', assuming not found - falling through to app routing'`,
-      {
-        appName,
-        semVer,
-      },
-    );
-    return undefined;
-  }
-
-  if (!versionInfo.DefaultFile) {
-    return undefined;
-  }
-
-  log.info(
-    `found '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}' - returning 302 to ${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
-    {
-      statusCode: 302,
-      routedPath: `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
-    },
-  );
-
-  return {
-    statusCode: 302,
-    redirectLocation: `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
-  };
-}
+export { loadAppFrame } from './load-app-frame';
+export { GetAppInfo } from './get-app-info';
+export { normalizePathPrefix } from './normalize-path-prefix';
+export { GetRoute, IGetRouteEvent, IGetRouteResult } from './get-route';
+export { RedirectToDefaultFile } from './redirect-default-file';
diff --git a/packages/microapps-router-lib/src/load-app-frame.spec.ts b/packages/microapps-router-lib/src/load-app-frame.spec.ts
new file mode 100644
index 00000000..c9e9f910
--- /dev/null
+++ b/packages/microapps-router-lib/src/load-app-frame.spec.ts
@@ -0,0 +1,51 @@
+import mockFs from 'mock-fs';
+import { loadAppFrame } from './load-app-frame';
+import Log from './lib/log';
+
+describe('loadAppFrame', () => {
+  afterEach(() => {
+    mockFs.restore();
+  });
+
+  it('should load appFrame.html from the default basePath', () => {
+    const mockHtmlContent = '<html><body>App Frame</body></html>';
+    mockFs({
+      'appFrame.html': mockHtmlContent,
+    });
+
+    expect(loadAppFrame({})).toBe(mockHtmlContent);
+  });
+
+  it('should load appFrame.html from a custom basePath', () => {
+    const mockHtmlContent = '<html><body>App Frame</body></html>';
+    const customBasePath = './custom';
+    mockFs({
+      [customBasePath]: {
+        'appFrame.html': mockHtmlContent,
+      },
+    });
+
+    expect(loadAppFrame({ basePath: customBasePath })).toBe(mockHtmlContent);
+  });
+
+  it('should throw an error if appFrame.html is not found', () => {
+    mockFs({});
+
+    expect(() => {
+      loadAppFrame({});
+    }).toThrowError('appFrame.html not found');
+  });
+
+  it('should log the error if appFrame.html is not found', () => {
+    mockFs({});
+    const logSpy = jest.spyOn(Log.Instance, 'error');
+
+    try {
+      loadAppFrame({});
+    } catch {
+      // Catch the error to allow the test to continue
+    }
+
+    expect(logSpy).toHaveBeenCalledWith('appFrame.html not found');
+  });
+});
diff --git a/packages/microapps-router-lib/src/load-app-frame.ts b/packages/microapps-router-lib/src/load-app-frame.ts
new file mode 100644
index 00000000..b402090e
--- /dev/null
+++ b/packages/microapps-router-lib/src/load-app-frame.ts
@@ -0,0 +1,36 @@
+import path from 'path';
+import { pathExistsSync, readFileSync } from 'fs-extra';
+import Log from './lib/log';
+
+const log = Log.Instance;
+
+/**
+ * Find and load the appFrame file
+ * @returns
+ */
+
+export function loadAppFrame({ basePath = '.' }: { basePath?: string }): string {
+  const paths = [
+    basePath,
+    path.join(basePath, '..'),
+    path.join(basePath, 'templates'),
+    basePath,
+    '/opt',
+    '/opt/templates',
+  ];
+
+  for (const pathRoot of paths) {
+    const fullPath = path.join(pathRoot, 'appFrame.html');
+    try {
+      if (pathExistsSync(fullPath)) {
+        log.info('found html file', { fullPath });
+        return readFileSync(fullPath, 'utf-8');
+      }
+    } catch {
+      // Don't care - we get here if stat throws because the file does not exist
+    }
+  }
+
+  log.error('appFrame.html not found');
+  throw new Error('appFrame.html not found');
+}
diff --git a/packages/microapps-router-lib/src/normalize-path-prefix.spec.ts b/packages/microapps-router-lib/src/normalize-path-prefix.spec.ts
new file mode 100644
index 00000000..ba6cc759
--- /dev/null
+++ b/packages/microapps-router-lib/src/normalize-path-prefix.spec.ts
@@ -0,0 +1,27 @@
+import { normalizePathPrefix } from './normalize-path-prefix';
+
+describe('normalizePathPrefix', () => {
+  it('should return an empty string when the input is an empty string', () => {
+    expect(normalizePathPrefix('')).toBe('');
+  });
+
+  it('should add a leading slash if it does not exist', () => {
+    expect(normalizePathPrefix('path')).toBe('/path');
+  });
+
+  it('should not add a leading slash if it already exists', () => {
+    expect(normalizePathPrefix('/path')).toBe('/path');
+  });
+
+  it('should remove a trailing slash if it exists', () => {
+    expect(normalizePathPrefix('/path/')).toBe('/path');
+  });
+
+  it('should remove a trailing slash and add a leading slash if needed', () => {
+    expect(normalizePathPrefix('path/')).toBe('/path');
+  });
+
+  it('should return the same input when it starts with a slash and does not end with a slash', () => {
+    expect(normalizePathPrefix('/path/subpath')).toBe('/path/subpath');
+  });
+});
diff --git a/packages/microapps-router-lib/src/normalize-path-prefix.ts b/packages/microapps-router-lib/src/normalize-path-prefix.ts
new file mode 100644
index 00000000..3d404c71
--- /dev/null
+++ b/packages/microapps-router-lib/src/normalize-path-prefix.ts
@@ -0,0 +1,18 @@
+/**
+ * Ensure that the path starts with a / and does not end with a /
+ *
+ * @param pathPrefix
+ * @returns
+ */
+
+export function normalizePathPrefix(pathPrefix: string): string {
+  let normalizedPathPrefix = pathPrefix;
+  if (normalizedPathPrefix !== '' && !normalizedPathPrefix.startsWith('/')) {
+    normalizedPathPrefix = '/' + pathPrefix;
+  }
+  if (normalizedPathPrefix.endsWith('/')) {
+    normalizedPathPrefix = normalizedPathPrefix.substring(0, normalizedPathPrefix.length - 1);
+  }
+
+  return normalizedPathPrefix;
+}
diff --git a/packages/microapps-router-lib/src/redirect-default-file.spec.ts b/packages/microapps-router-lib/src/redirect-default-file.spec.ts
new file mode 100644
index 00000000..f7e3758a
--- /dev/null
+++ b/packages/microapps-router-lib/src/redirect-default-file.spec.ts
@@ -0,0 +1,98 @@
+import { DBManager, Version } from '@pwrdrvr/microapps-datalib';
+import { AppVersionCache } from './app-cache';
+import { RedirectToDefaultFile } from './redirect-default-file';
+
+jest.mock('./app-cache');
+
+describe('RedirectToDefaultFile', () => {
+  const mockDbManager = {} as DBManager;
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should return undefined if GetVersionInfo throws an error', async () => {
+    (AppVersionCache.GetInstance as jest.Mock).mockImplementation(() => {
+      return {
+        GetVersionInfo: () => {
+          throw new Error('GetVersionInfo error');
+        },
+      };
+    });
+
+    const result = await RedirectToDefaultFile({
+      dbManager: mockDbManager,
+      appName: 'testApp',
+      semVer: '1.0.0',
+      appNameOrRootTrailingSlash: '',
+    });
+
+    expect(result).toBeUndefined();
+  });
+
+  it('should return undefined if versionInfo is undefined', async () => {
+    (AppVersionCache.GetInstance as jest.Mock).mockImplementation(() => {
+      return {
+        GetVersionInfo: () => undefined,
+      };
+    });
+
+    const result = await RedirectToDefaultFile({
+      dbManager: mockDbManager,
+      appName: 'testApp',
+      semVer: '1.0.0',
+      appNameOrRootTrailingSlash: '',
+    });
+
+    expect(result).toBeUndefined();
+  });
+
+  it('should return undefined if versionInfo.DefaultFile is not set', async () => {
+    const versionInfo: Version = {
+      AppName: 'testApp',
+      SemVer: '1.0.0',
+      // @ts-expect-error this is a test after-all
+      DefaultFile: undefined,
+    };
+    (AppVersionCache.GetInstance as jest.Mock).mockImplementation(() => {
+      return {
+        GetVersionInfo: () => versionInfo,
+      };
+    });
+
+    const result = await RedirectToDefaultFile({
+      dbManager: mockDbManager,
+      appName: 'testApp',
+      semVer: '1.0.0',
+      appNameOrRootTrailingSlash: '',
+    });
+
+    expect(result).toBeUndefined();
+  });
+
+  it('should return a redirect with status code 302 when versionInfo.DefaultFile is set', async () => {
+    // @ts-expect-error This is ok to be incomplete for the test
+    const versionInfo: Version = {
+      AppName: 'testApp',
+      SemVer: '1.0.0',
+      DefaultFile: 'index.html',
+    };
+    (AppVersionCache.GetInstance as jest.Mock).mockImplementation(() => {
+      return {
+        GetVersionInfo: () => versionInfo,
+      };
+    });
+
+    const result = await RedirectToDefaultFile({
+      dbManager: mockDbManager,
+      appName: 'testApp',
+      semVer: '1.0.0',
+      appNameOrRootTrailingSlash: 'testapp/',
+    });
+
+    expect(result).toEqual({
+      statusCode: 302,
+      redirectLocation: '/testapp/1.0.0/index.html',
+    });
+  });
+});
diff --git a/packages/microapps-router-lib/src/redirect-default-file.ts b/packages/microapps-router-lib/src/redirect-default-file.ts
new file mode 100644
index 00000000..24378a78
--- /dev/null
+++ b/packages/microapps-router-lib/src/redirect-default-file.ts
@@ -0,0 +1,79 @@
+import { DBManager, Version } from '@pwrdrvr/microapps-datalib';
+import { AppVersionCache } from './app-cache';
+import { IGetRouteResult } from './get-route';
+import Log from './lib/log';
+
+const log = Log.Instance;
+
+/**
+ * Redirect the request to app/x.y.z/? to app/x.y.z/{defaultFile}
+ * @param response
+ * @param normalizedPathPrefix
+ * @param appName
+ * @param semVer
+ * @returns
+ */
+
+export async function RedirectToDefaultFile(opts: {
+  dbManager: DBManager;
+  normalizedPathPrefix?: string;
+  appName: string;
+  semVer: string;
+  appNameOrRootTrailingSlash: string;
+}): Promise<IGetRouteResult | undefined> {
+  const {
+    dbManager,
+    normalizedPathPrefix = '',
+    appName,
+    appNameOrRootTrailingSlash,
+    semVer,
+  } = opts;
+  let versionInfo: Version | undefined;
+
+  try {
+    // Get the cache
+    const appVersionCache = AppVersionCache.GetInstance({ dbManager });
+
+    versionInfo = await appVersionCache.GetVersionInfo({
+      key: { AppName: appName, SemVer: semVer },
+    });
+  } catch (error) {
+    log.info(
+      `LoadVersion threw for '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}' - falling through to app routing'`,
+      {
+        appName,
+        semVer,
+        error,
+      },
+    );
+    return undefined;
+  }
+
+  if (versionInfo === undefined) {
+    log.info(
+      `LoadVersion returned undefined for '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}', assuming not found - falling through to app routing'`,
+      {
+        appName,
+        semVer,
+      },
+    );
+    return undefined;
+  }
+
+  if (!versionInfo.DefaultFile) {
+    return undefined;
+  }
+
+  log.info(
+    `found '${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}' - returning 302 to ${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
+    {
+      statusCode: 302,
+      routedPath: `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
+    },
+  );
+
+  return {
+    statusCode: 302,
+    redirectLocation: `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${semVer}/${versionInfo.DefaultFile}`,
+  };
+}
diff --git a/packages/microapps-router-lib/src/route-app.spec.ts b/packages/microapps-router-lib/src/route-app.spec.ts
new file mode 100644
index 00000000..1d87202b
--- /dev/null
+++ b/packages/microapps-router-lib/src/route-app.spec.ts
@@ -0,0 +1,128 @@
+import { DBManager } from '@pwrdrvr/microapps-datalib';
+import { AppVersionCache } from './app-cache';
+import { RouteApp } from './route-app';
+
+jest.mock('./app-cache');
+
+describe('RouteApp', () => {
+  const mockDbManager = {} as DBManager;
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  const testCases = [
+    {
+      caseName: 'should route simple app with iframe startup and no versions specified',
+      possibleSemVerPathAfterAppVersionInfo: undefined,
+      possibleSemVerPathNextDataVersionInfo: undefined,
+      possibleSemVerQueryVersionInfo: undefined,
+      possibleSemVerQuery: undefined,
+      defaultVersion: '1.0.0',
+      expectedStatusCode: 200,
+      expectedAppName: 'testApp',
+      expectedSemVer: '1.0.0',
+      expectedStartupType: 'iframe',
+    },
+    {
+      caseName: 'uses the path specified version instead of the default version',
+      possibleSemVerPathAfterAppVersionInfo: {
+        SemVer: '1.0.1',
+        DefaultFile: 'index.html',
+        StartupType: 'iframe',
+      },
+      possibleSemVerPathNextDataVersionInfo: undefined,
+      possibleSemVerQueryVersionInfo: undefined,
+      possibleSemVerQuery: undefined,
+      defaultVersion: '1.0.0',
+      expectedStatusCode: 200,
+      expectedAppName: 'testApp',
+      expectedSemVer: '1.0.1',
+      expectedStartupType: undefined,
+    },
+    {
+      caseName: 'uses the query string specified version instead of the default version',
+      possibleSemVerPathAfterAppVersionInfo: undefined,
+      possibleSemVerPathNextDataVersionInfo: undefined,
+      possibleSemVerQueryVersionInfo: {
+        SemVer: '1.0.3',
+        DefaultFile: 'index.html',
+        StartupType: 'iframe',
+      },
+      possibleSemVerQuery: '1.0.3',
+      defaultVersion: '1.0.0',
+      expectedStatusCode: 200,
+      expectedAppName: 'testApp',
+      expectedSemVer: '1.0.3',
+      expectedStartupType: 'iframe',
+    },
+    {
+      caseName: 'gives a 404 when the query string version does not exist',
+      possibleSemVerPathAfterAppVersionInfo: undefined,
+      possibleSemVerPathNextDataVersionInfo: undefined,
+      possibleSemVerQueryVersionInfo: undefined,
+      possibleSemVerQuery: '1.0.4',
+      defaultVersion: '1.0.0',
+      expectedStatusCode: 404,
+      expectedAppName: undefined,
+      expectedSemVer: undefined,
+      expectedStartupType: undefined,
+    },
+  ];
+
+  it.each(testCases)(
+    '$caseName',
+    async ({
+      possibleSemVerPathAfterAppVersionInfo,
+      possibleSemVerPathNextDataVersionInfo,
+      possibleSemVerQueryVersionInfo,
+      possibleSemVerQuery,
+      defaultVersion,
+      expectedStatusCode,
+      expectedAppName,
+      expectedSemVer,
+      expectedStartupType,
+    }) => {
+      (AppVersionCache.GetInstance as jest.Mock).mockImplementation(() => {
+        return {
+          GetVersionInfo: (options: { key: { AppName: string; SemVer: string } }) => {
+            if (options.key.SemVer === 'pathAfterApp') {
+              return possibleSemVerPathAfterAppVersionInfo;
+            }
+            if (options.key.SemVer === 'pathNextData') {
+              return possibleSemVerPathNextDataVersionInfo;
+            }
+            if (options.key.SemVer === 'query') {
+              return possibleSemVerQueryVersionInfo;
+            }
+            if (options.key.SemVer === defaultVersion) {
+              return { ...options.key, DefaultFile: 'index.html', StartupType: 'iframe' };
+            }
+            return undefined;
+          },
+          GetRules: () => ({ RuleSet: { default: { SemVer: defaultVersion } } }),
+        };
+      });
+
+      const result = await RouteApp({
+        dbManager: mockDbManager,
+        event: { dbManager: mockDbManager, locales: [], rawPath: '/testApp' },
+        appName: 'testApp',
+        possibleSemVerPathNextData: possibleSemVerPathNextDataVersionInfo
+          ? 'pathNextData'
+          : undefined,
+        possibleSemVerPathAfterApp: possibleSemVerPathAfterAppVersionInfo
+          ? 'pathAfterApp'
+          : undefined,
+        possibleSemVerQuery: possibleSemVerQueryVersionInfo ? 'query' : possibleSemVerQuery,
+        additionalParts: '',
+        appNameOrRootTrailingSlash: '',
+      });
+
+      expect(result.statusCode).toBe(expectedStatusCode);
+      expect(result.appName).toBe(expectedAppName);
+      expect(result.semVer).toBe(expectedSemVer);
+      expect(result.startupType).toBe(expectedStartupType);
+    },
+  );
+});
diff --git a/packages/microapps-router-lib/src/route-app.ts b/packages/microapps-router-lib/src/route-app.ts
new file mode 100644
index 00000000..f449e0ba
--- /dev/null
+++ b/packages/microapps-router-lib/src/route-app.ts
@@ -0,0 +1,199 @@
+import { DBManager, Version } from '@pwrdrvr/microapps-datalib';
+import { AppVersionCache } from './app-cache';
+import { IGetRouteEvent, IGetRouteResult } from './get-route';
+import Log from './lib/log';
+
+const log = Log.Instance;
+
+/**
+ * Lookup the version of the app to run
+ * @param event
+ * @param response
+ * @param appName
+ * @param additionalParts
+ * @param log
+ * @returns
+ */
+
+export async function RouteApp(opts: {
+  dbManager: DBManager;
+  event: IGetRouteEvent;
+  appName: string;
+  possibleSemVerPathNextData?: string;
+  possibleSemVerPathAfterApp?: string;
+  possibleSemVerQuery?: string;
+  additionalParts: string;
+  normalizedPathPrefix?: string;
+  appNameOrRootTrailingSlash: string;
+}): Promise<IGetRouteResult> {
+  const {
+    dbManager,
+    event,
+    normalizedPathPrefix = '',
+    appName,
+    possibleSemVerPathNextData,
+    possibleSemVerPathAfterApp,
+    possibleSemVerQuery,
+    additionalParts,
+    appNameOrRootTrailingSlash,
+  } = opts;
+
+  let versionInfoToUse: Version | undefined;
+
+  const appVersionCache = AppVersionCache.GetInstance({ dbManager });
+
+  // Check if the semver placeholder is actually a defined version
+  const possibleSemVerPathAfterAppVersionInfo = possibleSemVerPathAfterApp
+    ? await appVersionCache.GetVersionInfo({
+        key: { AppName: appName, SemVer: possibleSemVerPathAfterApp },
+      })
+    : undefined;
+  const possibleSemVerPathNextDataVersionInfo = possibleSemVerPathNextData
+    ? await appVersionCache.GetVersionInfo({
+        key: { AppName: appName, SemVer: possibleSemVerPathNextData },
+      })
+    : undefined;
+  const possibleSemVerQueryVersionInfo = possibleSemVerQuery
+    ? await appVersionCache.GetVersionInfo({
+        key: { AppName: appName, SemVer: possibleSemVerQuery },
+      })
+    : undefined;
+
+  // If there is a version in the path, use it
+  const possibleSemVerPathVersionInfo =
+    possibleSemVerPathAfterAppVersionInfo || possibleSemVerPathNextDataVersionInfo;
+  if (possibleSemVerPathVersionInfo) {
+    //
+    // If this is an iframe app then it's a startup request
+    // If this is a direct app then it's a regular request to send to the app
+    //
+    // This is a version, and it's in the path already, route the request to it
+    // without creating iframe
+    return {
+      statusCode: 200,
+      appName,
+      semVer: possibleSemVerPathVersionInfo.SemVer,
+      ...(possibleSemVerPathVersionInfo?.URL ? { url: possibleSemVerPathVersionInfo?.URL } : {}),
+      ...(possibleSemVerPathVersionInfo?.Type
+        ? {
+            type:
+              possibleSemVerPathVersionInfo?.Type === 'lambda'
+                ? 'apigwy'
+                : possibleSemVerPathVersionInfo?.Type,
+          }
+        : {}),
+    };
+  } else if (possibleSemVerQueryVersionInfo) {
+    // We got a version for the query string, but it's not in the path,
+    // so fall back to normal routing (create an iframe or direct route)
+    versionInfoToUse = possibleSemVerQueryVersionInfo;
+  } else if (possibleSemVerQuery) {
+    // We got a version in the query string but it does not exist
+    // This needs to 404 as this is a very specific request for a specific version
+    log.error(`could not find app ${appName}, for path ${event.rawPath} - returning 404`, {
+      statusCode: 404,
+    });
+
+    return {
+      statusCode: 404,
+      errorMessage: `Router - Could not find app: ${event.rawPath}, ${appName}`,
+    };
+  } else {
+    //
+    // TODO: Get the incoming attributes of user
+    // For logged in users, these can be: department, product type,
+    //  employee, office, division, etc.
+    // For anonymous users this can be: geo region, language,
+    // browser, IP, CIDR, ASIN, etc.
+    //
+    // The Rules can be either a version or a distribution of versions,
+    // including default, for example:
+    //     80% to 1.1.0, 20% to default (1.0.3)
+    //
+    const rules = await appVersionCache.GetRules({ key: { AppName: appName } });
+    const defaultVersion = rules?.RuleSet.default?.SemVer;
+
+    if (defaultVersion == null) {
+      log.error(`could not find app ${appName}, for path ${event.rawPath} - returning 404`, {
+        statusCode: 404,
+      });
+
+      return {
+        statusCode: 404,
+        errorMessage: `Router - Could not find app: ${event.rawPath}, ${appName}`,
+      };
+    }
+
+    const defaultVersionInfo = await appVersionCache.GetVersionInfo({
+      key: { AppName: appName, SemVer: defaultVersion },
+    });
+
+    versionInfoToUse = defaultVersionInfo;
+  }
+
+  if (!versionInfoToUse) {
+    log.error(
+      `could not find version info for app ${appName}, for path ${event.rawPath} - returning 404`,
+      {
+        statusCode: 404,
+      },
+    );
+
+    return {
+      statusCode: 404,
+      errorMessage: `Router - Could not find version info for app: ${event.rawPath}, ${appName}`,
+    };
+  }
+
+  if (versionInfoToUse?.StartupType === 'iframe' || !versionInfoToUse?.StartupType) {
+    // Prepare the iframe contents
+    let appVersionPath: string;
+    if (
+      versionInfoToUse?.Type !== 'static' &&
+      (versionInfoToUse?.DefaultFile === undefined ||
+        versionInfoToUse?.DefaultFile === '' ||
+        additionalParts !== '')
+    ) {
+      // KLUDGE: We're going to take a missing default file to mean that the
+      // app type is Next.js (or similar) and that it wants no trailing slash after the version
+      // TODO: Move this to an attribute of the version
+      appVersionPath = `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${versionInfoToUse.SemVer}`;
+      if (additionalParts !== '') {
+        appVersionPath += `/${additionalParts}`;
+      }
+    } else {
+      // Linking to the file directly means this will be peeled off by the S3 route
+      // That means we won't have to proxy this from S3
+      appVersionPath = `${normalizedPathPrefix}/${appNameOrRootTrailingSlash}${versionInfoToUse.SemVer}/${versionInfoToUse.DefaultFile}`;
+    }
+
+    return {
+      statusCode: 200,
+      appName,
+      semVer: versionInfoToUse.SemVer,
+      startupType: 'iframe',
+      ...(versionInfoToUse?.URL ? { url: versionInfoToUse?.URL } : {}),
+      ...(versionInfoToUse?.Type
+        ? { type: versionInfoToUse?.Type === 'lambda' ? 'apigwy' : versionInfoToUse?.Type }
+        : {}),
+      iFrameAppVersionPath: appVersionPath,
+    };
+  } else {
+    // This is a direct app version, no iframe needed
+    if (versionInfoToUse?.Type === 'lambda') {
+      throw new Error('Invalid type for direct app version');
+    }
+    if (['apigwy', 'static'].includes(versionInfoToUse?.Type || '')) {
+      throw new Error('Invalid type for direct app version');
+    }
+
+    return {
+      statusCode: 200,
+      appName,
+      semVer: versionInfoToUse.SemVer,
+      startupType: 'direct',
+      ...(versionInfoToUse?.URL ? { url: versionInfoToUse?.URL } : {}),
+      ...(versionInfoToUse?.Type ? { type: versionInfoToUse?.Type } : {}),
+    };
+  }
+}
diff --git a/yarn.lock b/yarn.lock
index ce2aa132..cef8dc54 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3457,10 +3457,10 @@
     tiny-glob "^0.2.9"
     tslib "^2.4.0"
 
-"@pwrdrvr/microapps-app-nextjs-demo-cdk@0.6.3":
-  version "0.6.3"
-  resolved "https://registry.yarnpkg.com/@pwrdrvr/microapps-app-nextjs-demo-cdk/-/microapps-app-nextjs-demo-cdk-0.6.3.tgz#2916b8e6cd88b7072d2e25da3e6489c35ae09a38"
-  integrity sha512-EnL3ogfaVuIPQ1Fc0ned5me+UZ9jJA4l8v6NL9XZmoU5d87+r6BATIiOa7GUQAYlcJsXX6rcv3IkhB+WTWUP7A==
+"@pwrdrvr/microapps-app-nextjs-demo-cdk@0.7.0":
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/@pwrdrvr/microapps-app-nextjs-demo-cdk/-/microapps-app-nextjs-demo-cdk-0.7.0.tgz#642ac90289827104e0e18e5208de82fa915ca074"
+  integrity sha512-7z1K5IGaWbjLETspUNmXoH6xREs6lqgGknCFBg6yYtdq62o2UB22EAnItkVvpdGbSN4PYmNoWO4/H6j4I264Pg==
 
 "@pwrdrvr/microapps-app-release-cdk@0.5.3":
   version "0.5.3"
@@ -3717,6 +3717,13 @@
   resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz"
   integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
 
+"@types/mock-fs@4.13.1":
+  version "4.13.1"
+  resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-4.13.1.tgz#9201554ceb23671badbfa8ac3f1fa9e0706305be"
+  integrity sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/node-fetch@^2.5.8":
   version "2.6.1"
   resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz"
@@ -8489,6 +8496,11 @@ mnemonist@0.38.3:
   dependencies:
     obliterator "^1.6.1"
 
+mock-fs@5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.2.0.tgz#3502a9499c84c0a1218ee4bf92ae5bf2ea9b2b5e"
+  integrity sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==
+
 modify-values@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz"


Test patch:
diff --git a/tests/integration/nextjs-demo.spec.ts b/tests/integration/nextjs-demo.spec.ts
index a74202a2..b0aae024 100644
--- a/tests/integration/nextjs-demo.spec.ts
+++ b/tests/integration/nextjs-demo.spec.ts
@@ -65,10 +65,7 @@ describe('nextjs-demo', () => {
   itSkipBasicPrefix('should return JSON for home page _next/data route', async () => {
     const response = await axios.get(
       new URL(
-        posixPath.join(
-          baseUrl.pathname,
-          `/nextjs-demo/_next/data/${NEXTJS_DEMO_APP_VER}/index.json`,
-        ),
+        posixPath.join(baseUrl.pathname, `/_next/data/${NEXTJS_DEMO_APP_VER}/sv/nextjs-demo.json`),
         baseUrl,
       ).toString(),
       {
@@ -78,7 +75,6 @@ describe('nextjs-demo', () => {
     const data = response.data;
 
     expect(data).toMatchObject({
-      __N_SSG: true,
       pageProps: {
         allPostsData: [
           {