đź“Ł Introducing Dash `/pages` - A Dash 2.x Feature Preview

Hi everyone! I’m pleased to share with you a feature we’ve been working on for Dash 2.x: "Dash /pages". This is one of many new features coming soon in 2.x

Dash /pages started from a discussion with community member @AnnMarieW about how we could simplify creating multi-page Dash applications.

Background

Currently, creating a proper multi-page Dash app involves a fair amount of boilerplate code:

  • A dcc.Location callback that listens to the URL pathname and returns different layouts
  • Importing files from a folder and passing that content into that callback
  • Defining a bunch of dcc.Link components that are careful to use the same href paths as defined in the dcc.Location callback

and a heckuva lot more code if you want an A-Grade multi-page app experience:

  • A clientside_callback to update the tab’s title on page navigation
  • Setting suppress_callback_exceptions=True or validate_layout to avoid exceptions from callbacks defined in the pages
  • Defining the URL navigation components within app.layout including duplicating the the URL paths used in the dcc.Location callback
  • Setting <title> and <meta type="description"> tags in the html index_string if you want your page to be indexed by search engines or display nicely in Slack or social media
  • A handful of flask.redirect functions when you change URLs
  • A “404 Not Found” page if someone enters an invalid URL

Introducing Dash /pages

After a few prototypes, @AnnMarieW and I came up with a new API that provides a new simple and standard way to create multi-page Dash applications. With this new API, creating a multi-page app involves 4 steps:

1./pages - Add your new pages to a folder called pages/. Each file should contain a function or variable called layout
2. dash.register_page - Within each file within pages/, simply call dash.register_page(__name__).
3. dash.page_container - Pass dash.page_container into app.layout
4. dash.page_registry - Create headers and navbars by looping through dash.page_registry: an OrderedDict containing the page’s paths, names, titles, and more.

That’s it! Dash will now display the page when you visit that page in your browser and it will automatically hook in all of the functionality listed above (dcc.Location, redirects, 404s, updating the page title and description, setting validate_layout, and more).

Quickstart

pages/home.py

dash.register_page(__name__, path='/')

def layout():
    # ...
    return html.Div([...])

pages/historical_analysis.py

dash.register_page(__name__)

def layout():
    # ...
    return html.Div([...])

app.py:

import dash
import dash_bootstrap_components as dbc

app = dash.Dash(external_stylesheets=[dbc.themes.BOOTSTRAP])

app.layout = dbc.Container([
    dbc.NavbarSimple([
        dbc.NavItem(dbc.NavLink(page['name'], href=page['path'])),
        for page in dash.page_registry
    ]),
    
    dash.page_container
])

Then you could visit:

The URL path is created from the filename __name__ (replacing _ with -) and be manually overridden in dash.register_page(path=...). For example:

pages/historical_analysis.py

dash.register_page(__name__, path='/historical')

def layout():
    # ...
    return html.Div([...])

In this case, the layout would be displayed at http://localhost:8050/historical instead of http://localhost:8050/historical-analysis.

Creating Navigation

The pages that are registered can be accessed with dash.page_registry. This can be used to create navigation headers in your app.py.

For example, with dash-bootstrap-components:

app.py

import dash
import dash_bootstrap_components as dbc

app = dash.Dash(external_stylesheets=[dbc.themes.BOOTSTRAP])

app.layout = dbc.Container([
    dbc.NavbarSimple([
        dbc.NavItem(dbc.NavLink(page['name'], href=page['path'])),
        for page in dash.page_registry
    ]),
    
    dash.page_container
])

Or for our Dash Enterprise customers using the Design Kit

import dash_design_kit as ddk
app = dash.Dash(__name__)

app.layout = ddk.App([
    ddk.Header([
        ddk.Menu(dcc.Link(page['name'], href=page['path']))
        for page in dash.page_registry
    ]),

    dash.page_container
])

dash.page_container

dash.page_container defines where the page’s content should render.

Often this will be below a navigation header or to the right of a navigation sidebar.


Advanced Features

These features are all optional. If you don’t supply values here, the framework will take a best guess and supply them for you.

Custom Meta Tags & Titles

The page’s title defines what you see in your browser tab and what would appear as the website’s title in search results. By default, it is derived from the filename but it can also be set with title=

dash.register_page(__name__, title='Custom page title')

Similarly, the meta description can be set with description= and the meta image can be set with image=. Both of these tags are used as the preview when sharing a URL in a social media platforms. They’re also used in search engine results.

By default, Dash will look through your assets/ folder for an image that matches the page’s filename or else an image called app.<image_extension> (e.g. app.png or app.jpeg) or logo.<image_extension> (all image extensions are supported).

This image URL can also be set directly with app.register_page(image=...) e.g.

app.register_page(__name__, image='/assets/page-preview.png')

dash.page_registry

dash.page_registry is an OrderedDict. The keys are the module as set by __name__, e.g. pages.historical_analysis. The value is a dict with the parameters passed into register_page: path name, title, description, image, order, and layout. If these parameters aren’t supplied, then they are derived from the filename.

For example:
pages/historical_analysis.py

dash.register_page(__name__)

print(dash.page_registry)

would print:

OrderedDict([
    ('pages.historical_analysis', {
        'module': 'pages.historical_analysis', 
        'name': 'Historical analysis', 
        'title': 'Historical analysis',
        'description': 'Historical analysis',
        'order': None,
    }
])

Whereas:
pages/outlook.py

dash.register_page(__name__, path='/future', name='Future outlook', order=4)

print(dash.page_registry)

would print:

OrderedDict([
    ('pages.outlook', {
        'module': 'pages.outlook', 
        'path': '/future',
        'name': 'Future outlook', 
        'title': 'Future outlook',
        'description': 'Future outlook',
        'order': 4,
    }
])

OrderedDict values can be accessed just like regular dictionaries:

>>> print(dash.page_registry['pages.historical_analysis']['name'])
'Historical analysis'

The order of the items in page_registry is based off of the optional order= parameter:

dash.register_page(__name__, order=10)

If it’s not supplied, then the order is alphanumeric based off of the filename. This order is important when rendering the page menu’s dynamically in a loop. The page with the path / has order=0 by default.

Redirects

Redirects can be set with the redirect_from=[...] parameter in register_page:

pages/historical_analysis.py

dash.register_page(
    __name__,
    path='/historical',
    redirect_from=['/historical-analysis', '/historical-outlook']
)

Custom 404 Pages

404 pages can display content when the URL isn’t found. By default, a simple content is displayed:

html.Div([
    html.H2('404 - Page not found'),
    html.Div(html.A('Return home', href='/')
])

However this can be customized by creating a file called not_found_404.py

Defining Multiple Pages within a Single File

You can also pass layout= directly into register_page. Here’s a quick multi-page app written in a single file:

app.register_page('historical_analysis', path='/historical-analysis', layout=html.Div(['Historical Analysis Page'])
app.register_page('forecast', path='/forecast', layout=html.Div(['Forecast Page'])

app.layout = dbc.Container([
    dbc.NavbarSimple([
        dbc.NavItem(dbc.NavLink(page['name'], href=page['path'])),
        for page in dash.page_registry
    ]),
    
    dash.page_container
])

However, we recommend splitting out the page layouts into their own files in pages/ to keep things more organized and to keep your files from becoming too long!


Available to Preview

A prototype has been built and open-sourced in GitHub - plotly/dash-multi-page-app-plugin. Follow the instructions in that repository to give it a try! In this preview version it is implemented as a Dash plugin, so you need to set:

dash.Dash(__name__, plugins=[pages_plugin])

The source code for this functionality is available in pages_plugin.py. It uses standard Dash features like dcc.Location, plugins, and interpolate_index, so if you’ve created a multi-page Dash app before it should look familiar.

In Dash 2.x, this will be integrated into the library and you won’t need to set the plugin.


We would love to hear your thoughts, feedback, and suggestions on this new feature. Let us know by leaving a reply below :arrow_down:

24 Likes

Love it! So much boilerplate avoided with this :slight_smile:

1 Like

This feature seems no less than a blessing :star_struck:
Thanks @chriddyp and @AnnMarieW for making Dash a lot easier.

2 Likes

This looks really cool! I’m interested to see how this develops, as there are a couple of features that I would probably need to see before switching over to something like this.

Variables in page URLs: I’m not sure how this would work in this setup as it adds significant complexity, but I have pages based on locations like /foo/{id}/bar, where callbacks that lay out the new page rely on the {id} from the URL.

Keeping state in URL params: again, probably not relevant for the use cases you would imagine here, but I’ve previously hacked together (admittedly horrible) solutions for having layout properties stored as parameters. Easy for a single page, but cumbersome for multiple pages.

I’ll be looking at implementing both of these properly in the coming months, so I’ll be looking for inspiration from features like this.

2 Likes

Wow, that looks like a great addition to Dash! Good work @chriddyp and @AnnMarieW :smiley:

At a glance, one feature that i am missing is the option to pass url parameters to the layout function of the page(s). Often when I have implemented multipage application, I would setup the logic so that if I enter

http://localhost:8050/historical-analysis?foo=bar

in my browser and hit enter, the layout function of the historical-analysis would be invoked with the a keyword argument foo with the value bar. I believe it should be relatively simple to add this feature to the framework - and i guess it would (at least partly) address the concerns of @benn0.

When I implemented a framework for multipage apps myself, I created each page as “an app blueprint” (i.e. including callbacks). The page can then be inserted into the main layout (thats how e.g. the dash-leaflet documentation was made) or registered on an endpoint (for multipage behaviour). When a page is registered onto the main app, prefixes are added automatically to all component ids, and callbacks are modified accordingly. What I really like about this approach is that you won’t have to worry about id collisions between pages. I was wondering if something similar could be achieved in this framework? :slight_smile:

4 Likes

Thanks for the feedback!

In this framework, you could still add your own callbacks that listen to the Location component and render dynamic content based on {id}.

So you might have pages/foo.py which would render the basic layout and then a callback that updates content inside foo.layout based off of {id}.

Would that work or do you imagine a simpler way to layout this content in a pages/ folder?

Yeah that’s a great idea. I’ll play around with this today :+1:

Another great idea! This feels reasonable. In the future I hope that the framework can do this for us automatically with some scoped IDs or something, but that’s a ways off. Recursing through the layout tree on each page seems reasonable to me. It won’t handle content that is dynamically updated on the page but it could handle whatever is returned from layout. Lemme give this a try today!

Alright, I’ve added query string support - Thanks for the suggestion @Emil ! Here’s what it looks like:

import dash

dash.register_page(__name__, path='/dashboard')

def layout(velocity=0, **other_unknown_query_strings):
    return dash.html.Div([
        dash.dcc.Input(id='velocity', value=velocity)
    ])

6 Likes

That looks great! Just what I imagined :smiley:

EDIT: You can see an example of how my implementation works here. It is not as polished nor elaborate as this proposal though :slight_smile:

Hey everyone,

I’m really excited about this new feature and I’m looking forward to seeing it in a future release of Dash. :confetti_ball:

This makes it ridiculously easy to build a multi-page app. I’d like to encourage you to take it for a spin and give us more feedback (Thanks @Emil and @Benn0 for your helpful comments)!
Feel free to share your examples, and if you run into issues or have suggestions for new features, let us know here or open an issue in Github.

The easiest way to try it out is to clone the repo , and put your own content in /pages.
GitHub - plotly/dash-multi-page-app-plugin

Here’s a another example like the quickstart above: I changed the sample apps in /pages to be:

- pages
   |-- not_found_404.py
   |-- page1.py
   |-- page2.py
   |-- page3.py
   |-- page4.py

in each of the pages, I added:

import dash
dash.register_page(__name__)

And to make page1.py the home page, I added the path

import dash
dash.register_page(__name__, path="/")

Below is the app.py file - and that’s it! I let the plug-in do the rest :slight_smile:

Remember that as of Dash 2.0, you no longer have to do the gymnastics of having both an app.py and an index.py file or pass around app. For example, prior to Dash 2.0, for a multi-page app in multiple files, you had to include
from app import app in each page that had callbacks.
See more information about the new @callback here.

This app includes an All-in-One component to change the themes. You can find it in the dash-bootstrap-templates library. Note - AIO components were added in Dash 2.0. Find more info here.

With 1 lines of code, you can include a “Change Theme” button (or a toggle switch for 2 themes) and the AIO component does the rest. You can style the figures with a Bootstrap theme too.


import dash
import pages_plugin
from dash import Dash, html, dcc
import dash_bootstrap_components as dbc
from aio.aio_components import ThemeChangerAIO

app = Dash(__name__, plugins=[pages_plugin], external_stylesheets=[dbc.themes.BOOTSTRAP])

navbar = dbc.NavbarSimple(
    dbc.DropdownMenu(
        [
            dbc.DropdownMenuItem(page["name"], href=page["path"])
            for page in dash.page_registry.values()
            if page["module"] != "pages.not_found_404"
        ],
        nav=True,
        label="More Pages",
    ),
    brand="Multi Page App Plugin Demo",
    color="primary",
    dark=True,
    className="mb-2",
)

app.layout = dbc.Container(
    [navbar, pages_plugin.page_container, ThemeChangerAIO(aio_id="theme"),],
    className="dbc",
    fluid=True,
)

if __name__ == "__main__":
    app.run_server(debug=True)

multi-page-plugin

7 Likes

Thanks for this great addition to the framework! I’m wondering, would it be possible to use these features in a way so that I have a layout and callbacks of each page separated in their own files?

1 Like

I believe this should be possible. You can define callbacks in any file you like, and if you use the new callback decorator that doesn’t need the app object, you don’t even need to worry about importing the app object.

3 Likes

Thought about this more tonight. Perhaps a syntax like:

register_page(
    path='/movies/{id}'
)

def layout(id=None):
    return 'You have selected movie {id}'

It could work with multiple levels of nesting:

register_page(
    path='/movies/{country}/{city}'
)

def layout(country=None, city=None):
    return f'You have selected {country} and {city}'
4 Likes

This is great thanks. Does that mean we would not be able to have URLs that would open a specifric tab on a webpage, looking at the query string support.

@snowde You should be able to open specific tabs… Have you tried it with the plug-in it and it did’t work? can you give an example of how you are doing this currently?

Here’s a new feature request:

This plug-in doesn’t yet support pages in nested folders in pages/ . For example, if you have this structure in pages:

- pages
   |-- home.py
   -chapter1
     |-- page1.py
     |-- page2.py
   -chapter2
     |-- page1.py
     |-- page2.py

I’m not sure about the best way to handle this, but it might be convenient if dash.page_registry would also have a folder variable that would be populated automatically. For the example above “pages.chapter1.app1” would also have folder=“chapter1”.

(Note – in this potential solution, the dash.page_registry dict is flat, with a key for each app in pages/)

Then if you were populating submenus for the app, you could create them like this:

html.Div(
    [
        html.Div(dcc.Link(page["name"], href=page["path"]))
        for page in dash.page_registry
        if page["folder"] == "chapter1"
    ]
)

Does anyone have any thoughts or see a better way to do this?

1 Like

Looks great! It looks similar to this project which creates a template of a multipage dash app https://github.com/ned2/slapdash

That project hasn’t been updated for around 2 years, so doesn’t contain the new features of dash 2. Would be really cool to have this new multipage code in a batteries included template. Is there something like this for dash 2 yet?

3 Likes

This is really exciting and definitely reduces the amount of steps needed to build a multi-page app. I took @Emil 's cool Burger component and built a very simple example app.

One area I wonder whether we can enhance is sharing data between pages. Now I’m using dcc.Store and callbacks, but perhaps there are quicker/simpler ways. @AnnMarieW and I were talking and she was thinking maybe we could do it through the dash.register.pages – make it so a dcc.Store is generated automatically through variable name or id.

This app has two pages and the main app.py. See structure below:

home.py (Meat)

import dash
from dash import html, dcc, callback, Output, Input
import pandas as pd
import plotly.express as px

df = pd.read_csv(
    "https://gist.githubusercontent.com/chriddyp/c78bf172206ce24f77d6363a2d754b59/raw/c353e8ef842413cae56ae3920b8fd78468aa4cb2/usa-agricultural-exports-2011.csv"
)
print(df.head())

dash.register_page(
    __name__,
    path="/",
    name="Meat",
    description="Welcome to my app",
    order=0,
    icon="fa fa-ambulance",
)

layout = html.Main(
    children=[
        html.H1("US Meat Exports (2011)"),
        html.Div(
            dcc.Dropdown(
                id="state-slctd",
                multi=False,
                value=df.state[4],
                options=[{"label": x, "value": x} for x in df.state],
                style={"width": "60%", "color": "#000000"},
                persistence_type="session",
                persistence=True,
            ),
            style={"display": "flex", "justifyContent": "center"},
        ),
        html.Div(
            id="graph-placeholder",
            style={"display": "flex", "justifyContent": "center"},
        ),
    ],
    style={"width": "100%", "height": "100vh"},
)


@callback(
    Output("graph-placeholder", "children"),
    Output("stored-state", "data"),
    Input("state-slctd", "value"),
)
def update_graph(selected):
    dff = df[df.state == selected]
    fig = px.bar(dff, x=["beef", "pork", "poultry"])
    return dcc.Graph(figure=fig, style={"width": "60%"}), selected

agri.py (Agriculture)

import dash
from dash import html, dcc, callback, Input, Output
import pandas as pd
import plotly.express as px

df = pd.read_csv(
    "https://gist.githubusercontent.com/chriddyp/c78bf172206ce24f77d6363a2d754b59/raw/c353e8ef842413cae56ae3920b8fd78468aa4cb2/usa-agricultural-exports-2011.csv"
)

dash.register_page(
    __name__, name="Agriculture", path="/agriculture", icon="fa fa-fw fa-heart-o"
)


def layout():
    return html.Main(
        [
            html.H1("US Agricultural Exports (2011)"),
            html.Div(
                id="graph-placeholder2",
                style={"display": "flex", "justifyContent": "center"},
            ),
        ],
        style={"width": "100%", "height": "100vh"},
    )


@callback(
    Output("graph-placeholder2", "children"),
    Input("stored-state", "data"),
)
def update_graph2(selected):
    print(selected)
    dff = df[df.state == selected]
    fig = px.bar(dff, x=["corn", "wheat", "cotton"], title=f"{selected}")
    return dcc.Graph(figure=fig, style={"width": "60%"})

And the main app.py

from dash import Dash, html, dcc
import dash
import pages_plugin
from dash_extensions import Burger

# Example CSS from the original demo.
external_css = [
    "https://negomi.github.io/react-burger-menu/example.css",
    "https://negomi.github.io/react-burger-menu/normalize.css",
    "https://negomi.github.io/react-burger-menu/fonts/font-awesome-4.2.0/css/font-awesome.min.css",
]

app = Dash(__name__, plugins=[pages_plugin], external_stylesheets=external_css)
# print(list(dash.page_registry.values())[1]['icon'])


app.layout = html.Div(
    [
        Burger(
            children=[
                html.Nav(
                    children=[
                        dcc.Link(
                            [html.I(className=page["icon"]), html.Span(page["name"])],
                            href=page["path"],
                            className="bm-item",
                            style={"display": "block"},
                        )
                        for page in dash.page_registry.values()
                    ],
                    className="bm-item-list",
                    style={"height": "100%"},
                )
            ]
        ),
        pages_plugin.page_container,
        dcc.Store(id="stored-state"),
    ],
    style={"height": "100%"},
)


if __name__ == "__main__":
    app.run_server(debug=True, port=8002)

2 Likes

Hey @adamschroeder

Thanks for posting another nice example. A few cool things here:

  • This shows how to have names for the pages that are different than your file names, and how to include other page specific data such as icons:

For example agri.py sets the name “Agriculture” and adds the icon here:

dash.register_page(
    __name__, name="Agriculture", path="/agriculture", icon="fa fa-fw fa-heart-o"
)

and in app.py it’s used to set the label on the link:

dcc.Link( [html.I(className=page["icon"]), html.Span(page["name"])], ....
  • Nice use of @Emil 's burger menu!

  • For dash-bootstrap-component users, you can also use the new dbc.Offcanvas component – new in V1.0. See more info here and it’s now easier to add icons

2 Likes