Repository: gradio-app/gradio
Pull request: Allow building multipage Gradio apps
Closes: https://github.com/gradio-app/gradio/issues/2654

Implemented multipage. This is the syntax:

```python
import gradio as gr

with gr.Blocks() as demo:
    name = gr.Textbox(label="Name")
    ...

with demo.route("Test", "/test"):
    num = gr.Number()
    ....

demo.launch()
```

See `demo/multipage/run.py` below:

![Recording 2025-01-24 at 08 04 25](https://github.com/user-attachments/assets/4c9a6e1a-bfe6-44c5-9efd-eb5616df6c24)

Initially I had implemented separate routes as separate Blocks but then it got real messy with trying to unify the queue, Stateholder and other backend things across multiple Blocks, so now we just keep the configs separate for different pages but the Blocks is shared.

Did not yet implement shared state or components across pages, because it's still unclear what the intended behaviour should be, still need to discuss.

Problem statement:
Support multiple pages in a gradio app
- [x] I have searched to see if a similar issue already exists.


**Is your feature request related to a problem? Please describe.**  
It would be good to distinct pages for different parts of a gradio app, including the ability to navigate directly to that page using the url. This feature should also add some kind of navigation bar/ structure in order to navigate the application.

**Describe the solution you'd like**  
Something like this would be cool:

```py
import gradio as gr

gr.Blocks():
  gr.Page("My amazing page", route="/"):
    gr.Markdown("# My amazing page")
    gr.Textbox("Page one")
  gr.Page("My second amazing page", route="/page-two"):
    gr.Markdown("# My second amazing page")
    gr.Textbox("Page two")
```

This would generate an app with a client side router that shows the first page by default with some kind of navigation bar with a link to the first + second pages. Clicking the second page link would change the url and change what is displayed. the urls would be something like:

- `/#/`
- `/#/page-two`


We could use hashbangs `/#!/xxx` but i don't know if there is any value in doing that anymore, might help google a bit but I don't know how crawlable spaces are anyway. 

**Additional context**  
It is important that this features works well on huggingface spaces, since it is one of our most used platforms. This introduces certain challenges/ limitations as, even though spaces have their own subdomain now, they are typically used via the the spaces chrome embed. 

Additionally as we support embedding via the web component, it is important that gradio doesn't have too many opinions about the structure of the URL and works on any subpath.

With this in mind, I think the best solution is a hash-based router, as hashes are inherently very flexible and can be appended to any URL. This will work well in spaces (and can be made really nice with some tweaks to spaces themselves) and will work well when embedded on blogposts etc.

Hints from issue discussion:
Besides direct URLs, what functionality does a `gr.Page()` allow that a `gr.Tab()` doesn't? All sorts of interesting cases pop up with pages -- e.g. can pages be nested inside other pages similar to tabs? Can you `.load()` a Space with pages inside of another Gradio app? We could implement it but I'm not sure if the value is that high? 
Main thing is that it is a different level of hierarchy to tabs. Tabs and Pages are different, one could not replace the other but they fill a similar role. Nested Tabs are a _very_ poor experience for the end user and should be discouraged, tabs inside pages are nice and clean. This shift in hierarchy is also reflected in having a different levels of navigation for Pages vs Tabs, and allows us more flexibility in how we communicate them to the user.

> can pages be nested inside other pages similar to tabs?

Probably, nested routing is a thing

> Can you .load() a Space with pages inside of another Gradio app?

Probably, it would form a nested route.

Haven't thought about the implementation much and I'm not going to fight for this feature but it has been requested a couple of times. Value is middling, the URL thing makes it _very_ tempting. A cleaner Tab implementation might go someway to addressing the visual side of things, i dislike tabs as they stand.
One option is to mount multiple gradio apps on a single FastAPI object, and treat each mounted gradio app/route as a page in the overall app, I have a barebones example below extending the doc example from https://gradio.app/sharing-your-app/#mounting-within-another-fastapi-app

```
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
import gradio as gr

app = FastAPI()

HELLO_ROUTE = "/hello"
GOODBYE_ROUTE = "/goodbye"
iframe_dimensions = "height=300px width=1000px"

index_html = f'''
<h1>Put header here</h1>

<h3>
You can mount multiple gradio apps on a single FastAPI object for a multi-page app.
However if you mount a gradio app downstream of another gradio app, the downstream
apps will be stuck loading. 
</h3>

<h3>
So in particular if you mount a gradio app at the index route "/", then all your 
other mounted gradio apps will be stuck loading. But don't worry, you can still embed
your downstream gradio apps into the index route using iframes like I do here. In fact,
you probably want to do this anyway since its your index page, which you want to detail 
more fully with a jinja template. 
For a full example, you can see my <a href=https://yfu.one/>generative avatar webapp</a>
</h3>

<div>
<iframe src={HELLO_ROUTE} {iframe_dimensions}></iframe>
</div>

<div>
<iframe src={GOODBYE_ROUTE} {iframe_dimensions}></iframe>
</div>

'''

@app.get("/", response_class=HTMLResponse)
def index():
    return index_html


hello_app = gr.Interface(lambda x: "Hello, " + x + "!", "textbox", "textbox")
goodbye_app = gr.Interface(lambda x: "Goodbye, " + x + "!", "textbox", "textbox")


app = gr.mount_gradio_app(app, hello_app, path=HELLO_ROUTE)
app = gr.mount_gradio_app(app, goodbye_app, path=GOODBYE_ROUTE)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app)
```
(btw how do you embed a [gist](https://gist.github.com/1lint/60b1c6b9ba5d4b7efdf78a00ca448297) into comment?)

A key observation I've found is that you cannot mount a gradio app downstream of another mounted gradio app, or else the downstream gradio apps will be disabled. But you can still embed your downstream gradio apps into your upstream routes like I do in the example.

This is what I use for my generative avatar webapp https://yfu.one catering to ML/AI art enthusiasts (based on observations of aesthetic trends in social media). I have multiple pages of gradio apps at https://yfu.one/apps/front_ui/, https://yfu.one/apps/full_ui/, https://yfu.one/register, etc. 

Realizing I could make my webapp by just serving jinja templates with embedded gradio apps for reactive web components was a breakthrough moment for me after banging my head on react tutorials for some time. I hope the example can be useful for others seeking to turn their gradio app into a more fully featured webapp with just writing Python. 


Love to have this enhancement too, To me, Gradio is a framework to GUI-fy a function,  FastAPI a framework to Web-API-fy a function, so if a Gradio app instance can be mapped to a route or exposed as an URL-endpoint, it will open up potential to compose complex Gradio app out of simple atomic apps.   My use-case is to build a multi-lingual dictionary in Gradio for educational purpose.  
> Love to have this enhancement too, To me, Gradio is a framework to GUI-fy a function, FastAPI a framework to Web-API-fy a function, so if a Gradio app instance can be mapped to a route or exposed as an URL-endpoint, it will open up potential to compose complex Gradio app out of simple atomic apps. My use-case is to build a multi-lingual dictionary in Gradio for educational purpose.

Well said @wgong, I agree completely with your statement and the discussion above. In my case, I'm interested in grouping several LLMs Application built with Gradio and having each of those apps under a given route.
I also agree with this idea. I can see many use cases where this feature would be advantageous. Additionally, it would allow for more complex uses of Gradio, making it more of a universal GUI framework.

Should be possible with custom components Soon ™️ 
Greetings,

I am using Starlette (not Fast API) for a long time. I am following the [endpoints](https://www.starlette.io/endpoints/) pattern. (Yes, in Django context, I also prefer Class Based Views) So, I have the mindset to break a whole thing (one api entry uri, with multiple api endpoints) into small parts. When it comes to gradio, it's a little bit strange feeling to put things together for first time.

IMHO, the existing gradio flavor to mitigate such strange feeling is to use `TabbedInterface`, then separate each tab into different python files.

![image](https://github.com/gradio-app/gradio/assets/14121694/a2a06c2f-e083-4d0b-8ca7-8fdeeb87ee92)


Last week, I was following the [OAuth with External Providers example](https://www.gradio.app/guides/sharing-your-app#o-auth-with-external-providers) to enable gradio integration with GitHub Enterprise. It's a successful try. Instead of using decorators for defining routing, I am using [routes list pattern](https://www.starlette.io/routing/#http-routing). For now, it's just redirections to two gradio apps. But it's possible to define a static HTML page as a landing page with different links to different gradio apps.

I am fine with a gradio package without native multi pages support. I respect the project owner design decision (I guess the project target for gradio will not be something like [django-cms](https://www.django-cms.org/en/)). Eventually, the existing gradio users will become full stack developers. Then welcome to the Angular/React/Vue jungles. ;)

Index Page
```python
from starlette.endpoints import HTTPEndpoint
from starlette.requests import Request
from starlette.responses import RedirectResponse
from starlette.routing import Route


class Index(HTTPEndpoint):
    async def get(self, request: Request):
        user = request.session.get('user', None)

        if user:
            return RedirectResponse(url='/gradio/')
        else:
            return RedirectResponse(url='/login/')


routes = [
    Route('/', Index, name='index'),
]
```

routes.py
```python
from starlette.routing import Mount

from views.api.routes import routes as routes_api
from views.ui.root import routes as routes_root
from views.ui.auth import routes as routes_auth


# when using starlette
routes = [
    Mount('/api', routes=routes_api),
    Mount('/auth', routes=routes_auth),
    Mount('/', routes=routes_root),
]
```

main.py
```python
# snipped

# do login
app = gr.mount_gradio_app(app, login_blocks, path='/login')

# show gradio page if authentication is done
app = gr.mount_gradio_app(app, main_blocks, path="/gradio",
                          auth_dependency=get_user)

# add addtional routes
for route in routes:
    app.routes.append(route)
```

[multi_page_app](https://gist.github.com/brandon8863/3ee552870a066eba463d37f0b39a547a)

I'm very new to Gradio, but I agree that some form of a multi-page app example would be nice. This isn't perfect but it does work with the 30 minutes of testing I've put on it :)

![Screenshot 2024-07-07 130505](https://github.com/gradio-app/gradio/assets/2395094/e013d07e-1b55-4fb7-8027-e46afc6a3006)


@abidlabs @pngwn I agree that it would be better/easier to have some kind of multipage framework, although as a work-around, I figured out that under gr.Blocks, I could configure multiple gr.Rows with individual layouts and treat them as "pages", i.e. login, welcome page, desktop app, mobile app using visibility= to control the flow.

Commit message: chagnes

Patch to review:
diff --git a/.changeset/large-beans-retire.md b/.changeset/large-beans-retire.md
new file mode 100644
index 0000000000..5c34ae4ac6
--- /dev/null
+++ b/.changeset/large-beans-retire.md
@@ -0,0 +1,9 @@
+---
+"@gradio/client": minor
+"@gradio/core": minor
+"@self/app": minor
+"@self/spa": minor
+"gradio": minor
+---
+
+feat:Multipage
diff --git a/client/js/src/types.ts b/client/js/src/types.ts
index 95b27f0e86..7557b078f6 100644
--- a/client/js/src/types.ts
+++ b/client/js/src/types.ts
@@ -179,6 +179,15 @@ export interface Config {
 	show_api: boolean;
 	stylesheets: string[];
 	path: string;
+	current_page: string;
+	page?: Record<
+		string,
+		{
+			components: ComponentMeta[];
+			dependencies: Dependency[];
+			layout: any;
+		}
+	>;
 	protocol: "sse_v3" | "sse_v2.1" | "sse_v2" | "sse_v1" | "sse" | "ws";
 	max_file_size?: number;
 	theme_hash?: number;
diff --git a/demo/multipage/run.ipynb b/demo/multipage/run.ipynb
new file mode 100644
index 0000000000..360cebea1e
--- /dev/null
+++ b/demo/multipage/run.ipynb
@@ -0,0 +1 @@
+{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: multipage"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "import random\n", "\n", "with gr.Blocks() as demo:\n", "    name = gr.Textbox(label=\"Name\")\n", "    output = gr.Textbox(label=\"Output Box\")\n", "    greet_btn = gr.Button(\"Greet\")\n", "    @gr.on([greet_btn.click, name.submit], inputs=name, outputs=output)\n", "    def greet(name):\n", "        return \"Hello \" + name + \"!\"\n", "\n", "with demo.route(\"Up\") as incrementer_demo:\n", "    num = gr.Number()\n", "    incrementer_demo.load(lambda: random.randint(10, 40), None, num)\n", "\n", "    with gr.Row():\n", "        inc_btn = gr.Button(\"Increase\")\n", "        dec_btn = gr.Button(\"Decrease\")\n", "    inc_btn.click(fn=lambda x: x + 1, inputs=num, outputs=num, api_name=\"increment\")\n", "    dec_btn.click(fn=lambda x: x - 1, inputs=num, outputs=num, api_name=\"decrement\")\n", "\n", "if __name__ == \"__main__\":\n", "    demo.launch(ssr_mode=True)\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
\ No newline at end of file
diff --git a/demo/multipage/run.py b/demo/multipage/run.py
new file mode 100644
index 0000000000..a3ba5d0db3
--- /dev/null
+++ b/demo/multipage/run.py
@@ -0,0 +1,23 @@
+import gradio as gr
+import random
+
+with gr.Blocks() as demo:
+    name = gr.Textbox(label="Name")
+    output = gr.Textbox(label="Output Box")
+    greet_btn = gr.Button("Greet")
+    @gr.on([greet_btn.click, name.submit], inputs=name, outputs=output)
+    def greet(name):
+        return "Hello " + name + "!"
+
+with demo.route("Up") as incrementer_demo:
+    num = gr.Number()
+    incrementer_demo.load(lambda: random.randint(10, 40), None, num)
+
+    with gr.Row():
+        inc_btn = gr.Button("Increase")
+        dec_btn = gr.Button("Decrease")
+    inc_btn.click(fn=lambda x: x + 1, inputs=num, outputs=num, api_name="increment")
+    dec_btn.click(fn=lambda x: x - 1, inputs=num, outputs=num, api_name="decrement")
+
+if __name__ == "__main__":
+    demo.launch(ssr_mode=True)
diff --git a/gradio/blocks.py b/gradio/blocks.py
index 5a5eb73bb6..179375ced3 100644
--- a/gradio/blocks.py
+++ b/gradio/blocks.py
@@ -8,6 +8,7 @@
 import json
 import os
 import random
+import re
 import secrets
 import string
 import sys
@@ -69,7 +70,7 @@
 from gradio.helpers import create_tracker, skip, special_args
 from gradio.node_server import start_node_server
 from gradio.route_utils import API_PREFIX, MediaStream
-from gradio.routes import VERSION, App, Request
+from gradio.routes import INTERNAL_ROUTES, VERSION, App, Request
 from gradio.state_holder import SessionState, StateHolder
 from gradio.themes import Default as DefaultTheme
 from gradio.themes import ThemeClass as Theme
@@ -137,6 +138,7 @@ def __init__(
         self.share_token = secrets.token_urlsafe(32)
         self.parent: BlockContext | None = None
         self.rendered_in: Renderable | None = None
+        self.page: str
         self.is_rendered: bool = False
         self._constructor_args: list[dict]
         self.state_session_capacity = 10000
@@ -187,6 +189,7 @@ def render(self):
                 f"A block with id: {self._id} has already been rendered in the current Blocks."
             )
         if render_context is not None:
+            self.page = root_context.root_block.current_page
             render_context.add(self)
         if root_context is not None:
             root_context.blocks[self._id] = self
@@ -467,6 +470,7 @@ def fill_expected_parents(self):
                     pseudo_parent.parent = self
                     children.append(pseudo_parent)
                     pseudo_parent.add_child(child)
+                    pseudo_parent.page = child.page
                     if root_context:
                         root_context.blocks[pseudo_parent._id] = pseudo_parent
                 child.parent = pseudo_parent
@@ -521,6 +525,7 @@ def __init__(
         stream_every: float = 0.5,
         like_user_message: bool = False,
         event_specific_args: list[str] | None = None,
+        page: str = "",
     ):
         self.fn = fn
         self._id = _id
@@ -554,6 +559,7 @@ def __init__(
         ) or inspect.isasyncgenfunction(self.fn)
         self.renderable = renderable
         self.rendered_in = rendered_in
+        self.page = page
 
         # We need to keep track of which events are cancel events
         # so that the client can call the /cancel route directly
@@ -871,6 +877,7 @@ def set_event_trigger(
             stream_every=stream_every,
             like_user_message=like_user_message,
             event_specific_args=event_specific_args,
+            page=self.root_block.current_page,
         )
 
         self.fns[self.fn_id] = block_fn
@@ -878,7 +885,19 @@ def set_event_trigger(
         return block_fn, block_fn._id
 
     def get_config(self, renderable: Renderable | None = None):
-        config = {}
+        config = {
+            "page": {},
+            "components": [],
+            "dependencies": [],
+        }
+
+        def setup_page(page: str):
+            if page not in config["page"]:
+                config["page"][page] = {
+                    "layout": {"id": self._id, "children": []},
+                    "components": [],
+                    "dependencies": [],
+                }
 
         rendered_ids = []
 
@@ -888,7 +907,8 @@ def get_layout(block: Block):
                 return {"id": block._id}
             children_layout = []
             for child in block.children:
-                children_layout.append(get_layout(child))
+                layout = get_layout(child)
+                children_layout.append(layout)
             return {"id": block._id, "children": children_layout}
 
         if renderable:
@@ -896,8 +916,11 @@ def get_layout(block: Block):
         else:
             root_block = self.root_block
         config["layout"] = get_layout(root_block)
+        for root_child in config["layout"]["children"]:
+            block = self.blocks[root_child["id"]]
+            setup_page(block.page)
+            config["page"][block.page]["layout"]["children"].append(root_child)
 
-        config["components"] = []
         blocks_items = list(
             self.blocks.items()
         )  # freeze as list to prevent concurrent re-renders from changing the dict during loop, see https://github.com/gradio-app/gradio/issues/9991
@@ -930,11 +953,17 @@ def get_layout(block: Block):
                     block_config["api_info_as_output"] = block.api_info()  # type: ignore
                 block_config["example_inputs"] = block.example_inputs()  # type: ignore
             config["components"].append(block_config)
+            setup_page(block.page)
+            config["page"][block.page]["components"].append(block_config)
 
         dependencies = []
         for fn in self.fns.values():
             if renderable is None or fn.rendered_in == renderable:
-                dependencies.append(fn.get_config())
+                dependency_config = fn.get_config()
+                dependencies.append(dependency_config)
+                setup_page(fn.page)
+                config["page"][fn.page]["dependencies"].append(dependency_config)
+
         config["dependencies"] = dependencies
         return config
 
@@ -1136,6 +1165,9 @@ def __init__(
         self.root_path = os.environ.get("GRADIO_ROOT_PATH", "")
         self.proxy_urls = set()
 
+        self.pages: list[tuple[str, str]] = [["", "Home"]]
+        self.current_page = ""
+
         if self.analytics_enabled:
             is_custom_theme = not any(
                 self.theme.to_dict() == built_in_theme.to_dict()
@@ -2172,6 +2204,7 @@ def get_config_file(self) -> BlocksConfigDict:
             "fill_width": self.fill_width,
             "theme_hash": self.theme_hash,
             "pwa": self.pwa,
+            "pages": self.pages,
         }
         config.update(self.default_config.get_config())  # type: ignore
         config["connect_heartbeat"] = utils.connect_heartbeat(
@@ -2206,6 +2239,7 @@ def __exit__(self, exc_type: type[BaseException] | None = None, *args):
         self.progress_tracking = any(
             block_fn.tracks_progress for block_fn in self.fns.values()
         )
+        self.page = ""
         self.exited = True
 
     def clear(self):
@@ -2254,7 +2288,6 @@ def queue(
             blocks=self,
             default_concurrency_limit=default_concurrency_limit,
         )
-        self.config = self.get_config_file()
         self.app = App.create_app(self)
         return self
 
@@ -3033,3 +3066,43 @@ def get_event_targets(
             event = getattr(block, event_name)
             target_events.append(event)
         return target_events
+
+    @document()
+    def route(self, name: str, path: str | None = None) -> Blocks:
+        """
+        Adds a new page to the Blocks app.
+        Parameters:
+            name: The name of the page as it appears in the nav bar.
+            path: The URL suffix appended after your Gradio app's root URL to access this page (e.g. if path="/test", the page may be accessible e.g. at http://localhost:7860/test). If not provided, the path is generated from the name by converting to lowercase and replacing spaces with hyphens. Any leading or trailing forward slashes are stripped.
+        Example:
+            with gr.Blocks() as demo:
+                name = gr.Textbox(label="Name")
+                ...
+            with demo.route("Test", "/test"):
+                num = gr.Number()
+                ...
+        """
+        if get_blocks_context():
+            raise ValueError(
+                "You cannot create a route while inside a Blocks() context. Call route() outside the Blocks() context (unindent this line)."
+            )
+
+        if path:
+            path = path.strip("/")
+            valid_path_regex = re.compile(r"^[a-zA-Z0-9-._~!$&'()*+,;=:@\[\]]+$")
+            if not valid_path_regex.match(path):
+                raise ValueError(
+                    f"Path '{path}' contains invalid characters. Paths can only contain alphanumeric characters and the following special characters: -._~!$&'()*+,;=:@[]"
+                )
+        if path in INTERNAL_ROUTES:
+            raise ValueError(f"Route with path '{path}' already exists")
+        if path is None:
+            path = name.lower().replace(" ", "-")
+            path = "".join(
+                [letter for letter in path if letter.isalnum() or letter == "-"]
+            )
+        while path in INTERNAL_ROUTES or path in [page[0] for page in self.pages]:
+            path = "_" + path
+        self.pages.append((path, name))
+        self.current_page = path
+        return self
diff --git a/gradio/interface.py b/gradio/interface.py
index 2d02671033..fbc684a0d2 100644
--- a/gradio/interface.py
+++ b/gradio/interface.py
@@ -546,8 +546,6 @@ def __init__(
             self.render_examples()
             self.render_article()
 
-        self.config = self.get_config_file()
-
     def render_title_description(self) -> None:
         if self.title:
             Markdown(
diff --git a/gradio/routes.py b/gradio/routes.py
index 2c11548060..018d68d2a6 100644
--- a/gradio/routes.py
+++ b/gradio/routes.py
@@ -365,15 +365,7 @@ async def conditional_routing_middleware(
                 if (
                     getattr(blocks, "node_process", None) is not None
                     and blocks.node_port is not None
-                    and not path.startswith("/gradio_api")
-                    and path not in ["/config", "/favicon.ico"]
-                    and not path.startswith("/theme")
-                    and not path.startswith("/svelte")
-                    and not path.startswith("/static")
-                    and not path.startswith("/login")
-                    and not path.startswith("/logout")
-                    and not path.startswith("/manifest.json")
-                    and not path.startswith("/pwa_icon")
+                    and not any(path.startswith(f"/{url}") for url in INTERNAL_ROUTES)
                 ):
                     if App.app_port is None:
                         App.app_port = request.url.port or int(
@@ -524,18 +516,39 @@ def _(path: str):
                 )
             )
 
+        for page, _ in blocks.pages:
+
+            @app.get(f"/{page}", response_class=HTMLResponse)
+            @app.get(f"/{page}/", response_class=HTMLResponse)
+            def page_route(
+                request: fastapi.Request,
+                user: str = Depends(get_current_user),
+                page=page,
+            ):
+                return main(request, user, page)
+
         @app.head("/", response_class=HTMLResponse)
         @app.get("/", response_class=HTMLResponse)
-        def main(request: fastapi.Request, user: str = Depends(get_current_user)):
+        def main(
+            request: fastapi.Request,
+            user: str = Depends(get_current_user),
+            page: str = "",
+        ):
             mimetypes.add_type("application/javascript", ".js")
             blocks = app.get_blocks()
             root = route_utils.get_root_url(
-                request=request, route_path="/", root_path=app.root_path
+                request=request,
+                route_path=f"/{page}",
+                root_path=app.root_path,
             )
             if (app.auth is None and app.auth_dependency is None) or user is not None:
                 config = utils.safe_deepcopy(blocks.config)
                 config = route_utils.update_root_in_config(config, root)
                 config["username"] = user
+                config["components"] = config["page"][page]["components"]
+                config["dependencies"] = config["page"][page]["dependencies"]
+                config["layout"] = config["page"][page]["layout"]
+                config["current_page"] = page
             elif app.auth_dependency:
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
@@ -1741,3 +1754,19 @@ async def new_lifespan(app: FastAPI):
 
     app.mount(path, gradio_app)
     return app
+
+
+INTERNAL_ROUTES = [
+    "theme.css",
+    "robots.txt",
+    "pwa_icon",
+    "manifest.json",
+    "login",
+    "logout",
+    "svelte",
+    "config",
+    "static",
+    "assets",
+    "favicon.ico",
+    "gradio_api",
+]
diff --git a/guides/04_additional-features/09_multipage_apps b/guides/04_additional-features/09_multipage_apps
new file mode 100644
index 0000000000..3d8ef3af88
--- /dev/null
+++ b/guides/04_additional-features/09_multipage_apps
@@ -0,0 +1,9 @@
+# Multipage Apps
+
+Your Gradio app can support multiple pages with the `route()` method. 
+
+$code_multipage
+
+This allows you to define links to separate pages, each with a separate URL, which are all linked at the top of the app in a navbar. 
+
+Multipage apps currently do not support interactions between pages, e.g. an event listener on one page cannot output to a component on another page. Use `gr.Tabs()` for this type of functionality instead of routes.
diff --git a/guides/04_additional-features/09_environment-variables.md b/guides/04_additional-features/10_environment-variables.md
similarity index 100%
rename from guides/04_additional-features/09_environment-variables.md
rename to guides/04_additional-features/10_environment-variables.md
diff --git a/guides/04_additional-features/10_resource-cleanup.md b/guides/04_additional-features/11_resource-cleanup.md
similarity index 100%
rename from guides/04_additional-features/10_resource-cleanup.md
rename to guides/04_additional-features/11_resource-cleanup.md
diff --git a/js/app/src/routes/+layout.server.ts b/js/app/src/routes/+layout.server.ts
index a4e816fa45..129bd8d19c 100644
--- a/js/app/src/routes/+layout.server.ts
+++ b/js/app/src/routes/+layout.server.ts
@@ -7,7 +7,4 @@ export function load({ url }): void {
 	if (dev && url.pathname.startsWith("/theme")) {
 		redirect(308, `http://127.0.0.1:7860${pathname}${search}`);
 	}
-	if (url.pathname !== "/") {
-		redirect(308, "/");
-	}
 }
diff --git a/js/app/src/routes/[...catchall]/+page.ts b/js/app/src/routes/[...catchall]/+page.ts
index 4c65f4ce68..14ed4e0d91 100644
--- a/js/app/src/routes/[...catchall]/+page.ts
+++ b/js/app/src/routes/[...catchall]/+page.ts
@@ -21,7 +21,6 @@ export async function load({
 }> {
 	const api_url =
 		browser && !local_dev_mode ? new URL(".", location.href).href : server;
-	// console.log("API URL", api_url, "-", location.href, "-");
 	const app = await Client.connect(api_url, {
 		with_null_state: true,
 		events: ["data", "log", "status", "render"]
@@ -31,13 +30,26 @@ export async function load({
 		throw new Error("No config found");
 	}
 
+	let pathname = url.pathname;
+	if (pathname.startsWith("/")) {
+		pathname = pathname.slice(1);
+	}
+	if (pathname.endsWith("/")) {
+		pathname = pathname.slice(0, -1);
+	}
+	let page_config =
+		app.config.page && pathname in app.config.page
+			? app.config.page[pathname]
+			: app.config;
+	let page = app.config.page && pathname in app.config.page ? pathname : "";
+
 	const { create_layout, layout } = create_components(undefined);
 
 	await create_layout({
 		app,
-		components: app.config.components,
-		dependencies: app.config.dependencies,
-		layout: app.config.layout,
+		components: page_config.components,
+		dependencies: page_config.dependencies,
+		layout: page_config.layout,
 		root: app.config.root + app.config.api_prefix,
 		options: {
 			fill_height: app.config.fill_height
@@ -48,7 +60,7 @@ export async function load({
 
 	return {
 		Render: app.config?.auth_required ? Login : Blocks,
-		config: app.config,
+		config: { ...app.config, ...page_config, current_page: page },
 		api_url,
 		layout: layouts,
 		app
diff --git a/js/core/src/Blocks.svelte b/js/core/src/Blocks.svelte
index 4eb64e6f88..17f6de5717 100644
--- a/js/core/src/Blocks.svelte
+++ b/js/core/src/Blocks.svelte
@@ -51,6 +51,9 @@
 	export let max_file_size: number | undefined = undefined;
 	export let initial_layout: ComponentMeta | undefined = undefined;
 	export let css: string | null | undefined = null;
+	export let pages: [string, string][] = [];
+	export let current_page: string = "";
+
 	let {
 		layout: _layout,
 		targets,
@@ -71,6 +74,13 @@
 		ready = !!$_layout;
 	}
 
+	let old_dependencies = dependencies;
+	$: if (dependencies !== old_dependencies && render_complete) {
+		// re-run load triggers in SSR mode when page changes
+		handle_load_triggers();
+		old_dependencies = dependencies;
+	}
+
 	async function run(): Promise<void> {
 		await create_layout({
 			components,
@@ -607,12 +617,7 @@
 				a[i].setAttribute("target", "_blank");
 		}
 
-		// handle load triggers
-		dependencies.forEach((dep) => {
-			if (dep.targets.some((dep) => dep[1] === "load")) {
-				wait_then_trigger_api_call(dep.id);
-			}
-		});
+		handle_load_triggers();
 
 		if (!target || render_complete) return;
 
@@ -665,6 +670,14 @@
 		render_complete = true;
 	}
 
+	const handle_load_triggers = () => {
+		dependencies.forEach((dep) => {
+			if (dep.targets.some((dep) => dep[1] === "load")) {
+				wait_then_trigger_api_call(dep.id);
+			}
+		});
+	};
+
 	$: set_status($loading_status);
 
 	function update_status(
@@ -745,6 +758,18 @@
 	{/if}
 </svelte:head>
 
+{#if pages.length > 1}
+	<nav>
+		{#each pages as [route, label], i}
+			<a href={route.length ? route : "/"} class:active={route === current_page}
+				>{label}</a
+			>
+			{#if i < pages.length - 1}
+				<span>&bull;</span>
+			{/if}
+		{/each}
+	</nav>
+{/if}
 <div class="wrap" style:min-height={app_mode ? "100%" : "auto"}>
 	<div class="contain" style:flex-grow={app_mode ? "1" : "auto"}>
 		{#if $_layout && app.config}
@@ -873,6 +898,22 @@
 {/if}
 
 <style>
+	nav {
+		display: inline-flex;
+		flex-wrap: wrap;
+		gap: var(--size-2);
+		margin: 0 auto var(--layout-gap);
+		justify-content: center;
+	}
+	nav a:hover {
+		text-decoration: underline;
+	}
+	nav a.active {
+		font-weight: bold;
+	}
+	nav span {
+		color: var(--body-text-color-subdued);
+	}
 	.wrap {
 		display: flex;
 		flex-grow: 1;
diff --git a/js/spa/src/Index.svelte b/js/spa/src/Index.svelte
index 1414068b8b..1e667f7d25 100644
--- a/js/spa/src/Index.svelte
+++ b/js/spa/src/Index.svelte
@@ -35,6 +35,8 @@
 		username: string | null;
 		api_prefix?: string;
 		max_file_size?: number;
+		pages: [string, string][];
+		current_page: string;
 	}
 
 	let id = -1;


Code review comments:
diff_hunk: @@ -51,6 +51,9 @@
 	export let max_file_size: number | undefined = undefined;
 	export let initial_layout: ComponentMeta | undefined = undefined;
 	export let css: string | null | undefined = null;
+	export let pages: [string, string][] = [];
+	export let current_page: string = "";
path: js/core/src/Blocks.svelte
text: @user1:

```suggestion
	export let current_page = "";
```

diff_hunk: @@ -665,6 +670,14 @@
 		render_complete = true;
 	}
 
+	const handle_load_triggers = () => {
path: js/core/src/Blocks.svelte
text: @user1:
```suggestion
	const handle_load_triggers = (): void => {
```