diff --git a/ui/package-lock.json b/ui/package-lock.json
index eb0ce5cee7e783ecb44d18b2f9243a9b4082bcc8..19791488654e89024ababcdb9be6fe4b4eb3f168 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -16,14 +16,20 @@
         "@types/node": "^12.20.17",
         "@types/react": "^17.0.15",
         "@types/react-dom": "^17.0.9",
+        "axios": "^0.21.1",
         "mdi-material-ui": "^6.22.1",
         "react": "^17.0.2",
         "react-dom": "^17.0.2",
         "react-query": "^3.19.1",
+        "react-router": "^5.2.0",
         "react-router-dom": "^5.2.0",
         "react-scripts": "4.0.3",
         "typescript": "^4.3.5",
         "web-vitals": "^1.1.2"
+      },
+      "devDependencies": {
+        "@types/react-router": "^5.1.16",
+        "@types/react-router-dom": "^5.1.8"
       }
     },
     "node_modules/@babel/code-frame": {
@@ -3661,6 +3667,12 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/history": {
+      "version": "4.7.9",
+      "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
+      "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==",
+      "dev": true
+    },
     "node_modules/@types/html-minifier-terser": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
@@ -3754,6 +3766,27 @@
         "@types/react": "*"
       }
     },
+    "node_modules/@types/react-router": {
+      "version": "5.1.16",
+      "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.16.tgz",
+      "integrity": "sha512-8d7nR/fNSqlTFGHti0R3F9WwIertOaaA1UEB8/jr5l5mDMOs4CidEgvvYMw4ivqrBK+vtVLxyTj2P+Pr/dtgzg==",
+      "dev": true,
+      "dependencies": {
+        "@types/history": "*",
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-router-dom": {
+      "version": "5.1.8",
+      "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.8.tgz",
+      "integrity": "sha512-03xHyncBzG0PmDmf8pf3rehtjY0NpUj7TIN46FrT5n1ZWHPZvXz32gUyNboJ+xsL8cpg8bQVLcllptcQHvocrw==",
+      "dev": true,
+      "dependencies": {
+        "@types/history": "*",
+        "@types/react": "*",
+        "@types/react-router": "*"
+      }
+    },
     "node_modules/@types/react-transition-group": {
       "version": "4.4.2",
       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz",
@@ -4683,6 +4716,14 @@
         "node": ">=4"
       }
     },
+    "node_modules/axios": {
+      "version": "0.21.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
+      "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+      "dependencies": {
+        "follow-redirects": "^1.10.0"
+      }
+    },
     "node_modules/axobject-query": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
@@ -24680,6 +24721,12 @@
         "@types/node": "*"
       }
     },
+    "@types/history": {
+      "version": "4.7.9",
+      "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
+      "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==",
+      "dev": true
+    },
     "@types/html-minifier-terser": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz",
@@ -24773,6 +24820,27 @@
         "@types/react": "*"
       }
     },
+    "@types/react-router": {
+      "version": "5.1.16",
+      "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.16.tgz",
+      "integrity": "sha512-8d7nR/fNSqlTFGHti0R3F9WwIertOaaA1UEB8/jr5l5mDMOs4CidEgvvYMw4ivqrBK+vtVLxyTj2P+Pr/dtgzg==",
+      "dev": true,
+      "requires": {
+        "@types/history": "*",
+        "@types/react": "*"
+      }
+    },
+    "@types/react-router-dom": {
+      "version": "5.1.8",
+      "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.8.tgz",
+      "integrity": "sha512-03xHyncBzG0PmDmf8pf3rehtjY0NpUj7TIN46FrT5n1ZWHPZvXz32gUyNboJ+xsL8cpg8bQVLcllptcQHvocrw==",
+      "dev": true,
+      "requires": {
+        "@types/history": "*",
+        "@types/react": "*",
+        "@types/react-router": "*"
+      }
+    },
     "@types/react-transition-group": {
       "version": "4.4.2",
       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz",
@@ -25496,6 +25564,14 @@
       "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.3.2.tgz",
       "integrity": "sha512-5LMaDRWm8ZFPAEdzTYmgjjEdj1YnQcpfrVajO/sn/LhbpGp0Y0H64c2hLZI1gRMxfA+w1S71Uc/nHaOXgcCvGg=="
     },
+    "axios": {
+      "version": "0.21.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
+      "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+      "requires": {
+        "follow-redirects": "^1.10.0"
+      }
+    },
     "axobject-query": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index efbbf5d273e22be2afb0b5ea3875684d77f361ad..2a79810864564fa30e9a80451efe2955e4b0bb3e 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -8,14 +8,12 @@
     "@testing-library/jest-dom": "^5.14.1",
     "@testing-library/react": "^11.2.7",
     "@testing-library/user-event": "^12.8.3",
-    "@types/jest": "^26.0.24",
-    "@types/node": "^12.20.17",
-    "@types/react": "^17.0.15",
-    "@types/react-dom": "^17.0.9",
+    "axios": "^0.21.1",
     "mdi-material-ui": "^6.22.1",
     "react": "^17.0.2",
     "react-dom": "^17.0.2",
     "react-query": "^3.19.1",
+    "react-router": "^5.2.0",
     "react-router-dom": "^5.2.0",
     "react-scripts": "4.0.3",
     "typescript": "^4.3.5",
@@ -44,5 +42,13 @@
       "last 1 firefox version",
       "last 1 safari version"
     ]
+  },
+  "devDependencies": {
+    "@types/jest": "^26.0.24",
+    "@types/node": "^12.20.17",
+    "@types/react": "^17.0.15",
+    "@types/react-dom": "^17.0.9",
+    "@types/react-router": "^5.1.16",
+    "@types/react-router-dom": "^5.1.8"
   }
 }
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index b89a5bbc03d43c96c17b94578269ca27c13451cd..d588c5be7e2d2bedb83383332e7e0b852ca5022f 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,18 +1,60 @@
-import React from 'react';
 import './App.css';
-import ImageList from "./components/ImageList";
 import {BaseUrlProvider} from './api/baseUrlContext';
 import {QueryClient, QueryClientProvider} from "react-query";
 import UploadView from "./components/UploadView";
+import {BrowserRouter, Link, Route} from "react-router-dom";
+import ImageListPage from "./pages/ImageListPage";
+import {Redirect, Switch} from "react-router";
+import ImageDetailPage from "./pages/ImageDetailPage";
+import {AppBar, Button, Toolbar, Typography} from "@material-ui/core";
+import {useErrorDisplay} from "./components/ErrorContext";
 
 const queryClient = new QueryClient();
 
 function App() {
+    const [ErrorDisplay, ErrorWrapper] = useErrorDisplay();
+
     return (
         <BaseUrlProvider value="http://localhost:8080/">
             <QueryClientProvider client={queryClient}>
-                <UploadView />
-                <ImageList/>
+                <BrowserRouter>
+                    <AppBar position="static">
+                        <Toolbar>
+                            <Typography variant="h6">
+                                i.k8r.eu
+                            </Typography>
+                            <Button
+                                color="inherit"
+                                component={Link}
+                                to="/upload"
+                            >
+                                Upload
+                            </Button>
+                            <Button
+                                color="inherit"
+                                component={Link}
+                                to="/images"
+                            >
+                                Images
+                            </Button>
+                        </Toolbar>
+                    </AppBar>
+                    <ErrorDisplay/>
+                    <ErrorWrapper>
+                        <Switch>
+                            <Route path="/upload">
+                                <UploadView/>
+                            </Route>
+                            <Route path="/images">
+                                <ImageListPage/>
+                            </Route>
+                            <Route path="/i/:imageId">
+                                <ImageDetailPage/>
+                            </Route>
+                            <Redirect from="/" to="/images"/>
+                        </Switch>
+                    </ErrorWrapper>
+                </BrowserRouter>
             </QueryClientProvider>
         </BaseUrlProvider>
     );
diff --git a/ui/src/components/ImageList.tsx b/ui/src/components/ImageList.tsx
deleted file mode 100644
index f46895f91444e2e45f2a159d2acf7600cd8e006d..0000000000000000000000000000000000000000
--- a/ui/src/components/ImageList.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-import '../App.css';
-import {useListImages} from "../api/useListImages";
-import ImageView from "./ImageView";
-
-export default function ImageList() {
-    const {status, data, error} = useListImages();
-    return (
-        <div>
-            <p>{status}</p>
-            <p>{error as string}</p>
-            {data?.map(image => (
-                <ImageView
-                    key={image.id}
-                    image={image}
-                />
-            ))}
-        </div>
-    );
-}
diff --git a/ui/src/components/ImageListViewProps.tsx b/ui/src/components/ImageListViewProps.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0f7132b66d392c7a1cc753b3bb369f1b9f587d44
--- /dev/null
+++ b/ui/src/components/ImageListViewProps.tsx
@@ -0,0 +1,74 @@
+import {Fragment} from "react";
+import {Link} from "react-router-dom";
+import {Image} from "../api/model/Image";
+import {parseRatio} from "../metadata/Ratio";
+
+export interface ImageListViewProps {
+    images: Image[],
+}
+
+export function ImageListView({images}: ImageListViewProps) {
+    const rows: (Image | null)[][] = [];
+    if (images) {
+        let row = [];
+        for (let i = 0; i < images.length; i++) {
+            if (i % 4 === 0 && row.length !== 0) {
+                rows.push(row);
+                row = [];
+            }
+            row.push(images[i]);
+        }
+        rows.push([...row, ...([null, null, null].slice(0, 4 - row.length))]);
+    }
+
+    return (
+        <Fragment>
+            <style>
+                {`img {
+                    height: 100%;
+                    max-width: 100%;
+                    margin: 2px;
+                }
+                
+                a:first-child img {
+                margin-left: -8px;
+                }
+                
+                a:last-child img {
+                margin-right: -8px;
+                }`}
+            </style>
+            <div style={{padding: "0 8px", fontSize: 0}}>
+                {rows.map(row => {
+                    let ratios = 0;
+                    for (const image of row) {
+                        const [w, h] = image ? determineAspectRatio(image) : [1, 1];
+                        ratios += w / h;
+                    }
+
+                    return (
+                        <div style={{aspectRatio: "" + ratios + "/" + 1}}>
+                            {row.map(image => image && (
+                                <Link to={`/i/${image.id}`}>
+                                    <img src={image.url} alt=""/>
+                                </Link>
+                            ))}
+                        </div>
+                    )
+                })}
+            </div>
+        </Fragment>
+    )
+}
+
+function determineAspectRatio(image: Image): [number, number] {
+    const aspectRatio = parseRatio(image.metadata["AspectRatio"]);
+    if (aspectRatio === undefined) {
+        console.log("Did not find aspect ratio: ", image.id);
+        return [1, 1];
+    } else {
+        console.log("Found aspect ratio: ", image.id, aspectRatio.numerator, aspectRatio.denominator);
+        return [aspectRatio.numerator, aspectRatio.denominator];
+    }
+}
+
diff --git a/ui/src/components/ImageView.tsx b/ui/src/components/ImageView.tsx
deleted file mode 100644
index a8bc7400c9c4ba38fa2087e5f910f0ca99d0ae37..0000000000000000000000000000000000000000
--- a/ui/src/components/ImageView.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import {Image} from "../api/model/Image";
-import React, {useMemo, useState} from "react";
-import {useUpdateImage} from "../api/useUpdateImage";
-import {useDeleteImage} from "../api/useDeleteImage";
-import {parseMetadata} from "../metadata/ImageMetadata";
-import ImageMetadataView from "./ImageMetadataView";
-import {Button, List, ListItem, ListItemIcon, ListItemText, TextField} from "@material-ui/core";
-import {Event, Info} from "@material-ui/icons";
-import {File, Tag} from "mdi-material-ui";
-
-export interface ImageProps {
-    image: Image
-}
-
-export default function ImageView({image}: ImageProps) {
-    const {mutate: update, error: updateError, isLoading: updateLoading} = useUpdateImage();
-    const {mutate: remove, error: removeError, isLoading: removeLoading} = useDeleteImage();
-    const [title, setTitle] = useState<string>(image.title);
-    const [description, setDescription] = useState<string>(image.description);
-
-    const metadata = useMemo(() =>
-            parseMetadata(image.metadata),
-        [image]);
-
-    return (
-        <div>
-            <p>UpdateError: {JSON.stringify(updateError, null, 2)}</p>
-            <p>RemoveError: {JSON.stringify(removeError, null, 2)}</p>
-            <p>UpdateLoading: {JSON.stringify(updateLoading, null, 2)}</p>
-            <p>RemoveLoading: {JSON.stringify(removeLoading, null, 2)}</p>
-            <img src={image.url + "l"} alt=""/>
-            <List dense>
-                <ListItem dense>
-                    <ListItemIcon><Info/></ListItemIcon>
-                    <ListItemText primary="Id" secondary={image.id}/>
-                </ListItem>
-                <ListItem dense>
-                    <ListItemText inset primary="Owner" secondary={image.owner}/>
-                </ListItem>
-                <ListItem dense>
-                    <ListItemText inset primary="State" secondary={image.state}/>
-                </ListItem>
-                <ListItem dense>
-                    <ListItemIcon><Tag/></ListItemIcon>
-                    {/* TODO: Fix this ugly nesting */}
-                    <ListItemText
-                        primary="Title"
-                        secondary={<TextField
-                            fullWidth
-                            value={title}
-                            onChange={({target: {value}}) =>
-                                setTitle(value)}
-                        />}
-                    />
-                </ListItem>
-                <ListItem dense>
-                    {/* TODO: Fix this ugly nesting */}
-                    <ListItemText
-                        inset
-                        primary="Description"
-                        secondary={<TextField
-                            fullWidth
-                            value={description}
-                            onChange={({target: {value}}) =>
-                                setDescription(value)}
-                        />}
-                    />
-                </ListItem>
-                <ListItem dense>
-                    <ListItemIcon><File/></ListItemIcon>
-                    <ListItemText primary="Filename" secondary={image.original_name}/>
-                </ListItem>
-                <ListItem dense>
-                    <ListItemText inset primary="MIME Type" secondary={image.mime_type}/>
-                </ListItem>
-                <ListItem dense>
-                    <ListItemIcon><Event/></ListItemIcon>
-                    <ListItemText primary="Uploaded At" secondary={image.created_at}/>
-                </ListItem>
-                <ListItem dense>
-                    <ListItemText inset primary="Modified At" secondary={image.updated_at}/>
-                </ListItem>
-                <ImageMetadataView metadata={metadata}/>
-            </List>
-            <Button
-                onClick={() => update({
-                    ...image,
-                    title,
-                    description,
-                })}
-            >Save</Button>
-            <Button
-                onClick={() => remove(image)}
-            >Delete</Button>
-        </div>
-    )
-}
diff --git a/ui/src/pages/ImageDetailPage.tsx b/ui/src/pages/ImageDetailPage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..931187ded4854c848106b8c4ffad3c4583e0ae7d
--- /dev/null
+++ b/ui/src/pages/ImageDetailPage.tsx
@@ -0,0 +1,138 @@
+import {useUpdateImage} from "../api/useUpdateImage";
+import {useDeleteImage} from "../api/useDeleteImage";
+import {useEffect, useMemo, useState} from "react";
+import {parseMetadata} from "../metadata/ImageMetadata";
+import {
+    Button,
+    CircularProgress,
+    Grid,
+    LinearProgress,
+    List,
+    ListItem,
+    ListItemIcon,
+    ListItemText,
+    TextField
+} from "@material-ui/core";
+import {Delete, Event, Info, Save} from "@material-ui/icons";
+import {File, Tag} from "mdi-material-ui";
+import ImageMetadataView from "../components/ImageMetadataView";
+import {useGetImage} from "../api/useGetImage";
+import {useParams} from "react-router";
+import {ErrorPortal} from "../components/ErrorContext";
+import {ErrorAlert} from "../components/ErrorAlert";
+
+export interface ImageDetailPageParams {
+    imageId: string
+}
+
+export default function ImageDetailPage() {
+    const {imageId} = useParams<ImageDetailPageParams>();
+    const {data: image, error: imageError, isLoading: imageLoading} = useGetImage(imageId);
+    const {mutate: update, error: updateError, isLoading: updateLoading} = useUpdateImage();
+    const {mutate: remove, error: removeError, isLoading: removeLoading} = useDeleteImage();
+    const [title, setTitle] = useState<string>(image?.title || "");
+    const [description, setDescription] = useState<string>(image?.description || "");
+    useEffect(() => setTitle(image?.title || ""), [image?.title]);
+    useEffect(() => setDescription(image?.description || ""), [image?.description]);
+
+    const metadata = useMemo(() => parseMetadata(image?.metadata), [image]);
+
+    if (image === undefined || metadata === undefined) {
+        return (
+            <div>Error: 404</div>
+        );
+    }
+
+    return (
+        <div>
+            {imageLoading && (
+                <LinearProgress/>
+            )}
+            <ErrorPortal>
+                <ErrorAlert severity="error" error={imageError}/>
+                <ErrorAlert severity="error" error={updateError}/>
+                <ErrorAlert severity="error" error={removeError}/>
+            </ErrorPortal>
+            <Grid container>
+                <Grid item xs={12} md={6}>
+                    <img src={image.url + "l"} alt={image.title} style={{width: "100%"}}/>
+                    <List dense>
+                        <ListItem dense>
+                            <ListItemIcon><Info/></ListItemIcon>
+                            <ListItemText primary="Id" secondary={image.id}/>
+                        </ListItem>
+                        <ListItem dense>
+                            <ListItemText inset primary="Owner" secondary={image.owner}/>
+                        </ListItem>
+                        <ListItem dense>
+                            <ListItemText inset primary="State" secondary={image.state}/>
+                        </ListItem>
+                        <ListItem dense>
+                            <ListItemIcon><Tag/></ListItemIcon>
+                            {/* TODO: Fix this ugly nesting */}
+                            <ListItemText
+                                primary="Title"
+                                secondary={<TextField
+                                    fullWidth
+                                    value={title}
+                                    onChange={({target: {value}}) =>
+                                        setTitle(value)}
+                                />}
+                            />
+                        </ListItem>
+                        <ListItem dense>
+                            {/* TODO: Fix this ugly nesting */}
+                            <ListItemText
+                                inset
+                                primary="Description"
+                                secondary={<TextField
+                                    fullWidth
+                                    value={description}
+                                    onChange={({target: {value}}) =>
+                                        setDescription(value)}
+                                />}
+                            />
+                        </ListItem>
+                    </List>
+                    <Button
+                        variant="contained"
+                        color="primary"
+                        disabled={updateLoading}
+                        startIcon={updateLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Save/>}
+                        onClick={() => update({
+                            ...image,
+                            title,
+                            description,
+                        })}
+                    >Save</Button>
+                    <Button
+                        variant="contained"
+                        color="secondary"
+                        disabled={removeLoading}
+                        startIcon={removeLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Delete/>}
+                        onClick={() => remove(image)}
+                    >Delete</Button>
+                </Grid>
+                <Grid item xs={12} md={6}>
+                    <List dense>
+                        <ListItem dense>
+                            <ListItemIcon><File/></ListItemIcon>
+                            <ListItemText primary="Filename" secondary={image.original_name}/>
+                        </ListItem>
+                        <ListItem dense>
+                            <ListItemText inset primary="MIME Type" secondary={image.mime_type}/>
+                        </ListItem>
+                        <ListItem dense>
+                            <ListItemIcon><Event/></ListItemIcon>
+                            <ListItemText primary="Uploaded At" secondary={image.created_at}/>
+                        </ListItem>
+                        <ListItem dense>
+                            <ListItemText inset primary="Modified At" secondary={image.updated_at}/>
+                        </ListItem>
+                        <ImageMetadataView metadata={metadata}/>
+                    </List>
+                </Grid>
+            </Grid>
+        </div>
+    )
+}
diff --git a/ui/src/pages/ImageListPage.tsx b/ui/src/pages/ImageListPage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7649d588ceab63c4cc8744c03061f14f86145f59
--- /dev/null
+++ b/ui/src/pages/ImageListPage.tsx
@@ -0,0 +1,32 @@
+import {Fragment} from "react";
+import {useListImages} from "../api/useListImages";
+import {ImageList, ImageListItem, ImageListItemBar, LinearProgress} from "@material-ui/core";
+import {Link} from "react-router-dom";
+import {ErrorPortal} from "../components/ErrorContext";
+import {ErrorAlert} from "../components/ErrorAlert";
+
+export default function ImageListPage() {
+    const {data: images, error, isLoading} = useListImages();
+
+    return (
+        <Fragment>
+            {isLoading && (
+                <LinearProgress/>
+            )}
+            <ErrorPortal>
+                <ErrorAlert severity="error" error={error}/>
+            </ErrorPortal>
+            <ImageList cols={5}>
+                {images?.map(image => (
+                    <ImageListItem component={Link} to={`/i/${image.id}`}>
+                        <img src={image.url} alt={image.title}/>
+                        <ImageListItemBar
+                            title={image.title}
+                            subtitle={image.original_name}
+                        />
+                    </ImageListItem>
+                ))}
+            </ImageList>
+        </Fragment>
+    );
+}