diff --git a/web/source/package.json b/web/source/package.json
index a12d6fe77..8a561af42 100644
--- a/web/source/package.json
+++ b/web/source/package.json
@@ -34,6 +34,7 @@
     "pretty-bytes": "^5.6.0",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
+    "react-error-boundary": "^3.1.4",
     "reactify": "^1.1.1",
     "uglifyify": "^5.0.2",
     "wouter": "^2.8.0-alpha.2"
diff --git a/web/source/settings-panel/admin/index.js b/web/source/settings-panel/admin/index.js
new file mode 100644
index 000000000..551f71af2
--- /dev/null
+++ b/web/source/settings-panel/admin/index.js
@@ -0,0 +1,33 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+"use strict";
+
+const Promise = require("bluebird");
+const React = require("react");
+const { Route, Switch } = require("wouter");
+
+module.exports = function AdminPanel({oauth, routes}) {
+	return (
+		<Switch>
+			{routes.map(([path, component]) => {
+				return <Route key={path} path={path} component={component}/>;
+			})}
+		</Switch>
+	);
+};
\ No newline at end of file
diff --git a/web/source/settings-panel/components/error.js b/web/source/settings-panel/components/error.js
new file mode 100644
index 000000000..e5e0ff139
--- /dev/null
+++ b/web/source/settings-panel/components/error.js
@@ -0,0 +1,45 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+"use strict";
+
+const Promise = require("bluebird");
+const React = require("react");
+
+module.exports = function ErrorFallback({error, resetErrorBoundary}) {
+	return (
+		<div className="error">
+			<p>
+				{"An error occured, please report this on the "}
+				<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
+				{" or "}
+				<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
+				<br/>Include the details below:
+			</p>
+			<pre>
+				{error.name}: {error.message}
+			</pre>
+			<pre>
+				{error.stack}
+			</pre>
+			<p>
+				<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
+			</p>
+		</div>
+	);
+};
\ No newline at end of file
diff --git a/web/source/lib/submit.js b/web/source/settings-panel/components/submit.js
similarity index 100%
rename from web/source/lib/submit.js
rename to web/source/settings-panel/components/submit.js
diff --git a/web/source/settings-panel/index.js b/web/source/settings-panel/index.js
index c2586d12e..a69819f9c 100644
--- a/web/source/settings-panel/index.js
+++ b/web/source/settings-panel/index.js
@@ -22,23 +22,35 @@ const Promise = require("bluebird");
 const React = require("react");
 const ReactDom = require("react-dom");
 const { Link, Route, Switch, useRoute, Redirect } = require("wouter");
+const { ErrorBoundary } = require("react-error-boundary");
 
 const Auth = require("./components/auth");
+const ErrorFallback = require("./components/error");
+
 const oauthLib = require("./lib/oauth");
 
 require("./style.css");
 
+const UserPanel = require("./user");
+const AdminPanel = require("./admin");
+
 const nav = {
-	"User": [
-		["Profile", require("./user/profile.js")],
-		["Settings", require("./user/settings.js")],
-		["Customization", require("./user/customization.js")]
-	],
-	"Admin": [
-		["Instance Settings", require("./admin/settings.js")],
-		["Federation", require("./admin/federation.js")],
-		["Customization", require("./admin/customization.js")]
-	]
+	"User": {
+		Component: require("./user"),
+		entries: {
+			"Profile": require("./user/profile.js"),
+			"Settings": require("./user/settings.js"),
+			"Customization": require("./user/customization.js")
+		}
+	},
+	"Admin": {
+		Component: require("./admin"),
+		entries: {
+			"Instance Settings": require("./admin/settings.js"),
+			"Federation": require("./admin/federation.js"),
+			"Customization": require("./admin/customization.js")
+		}
+	}
 };
 
 function urlSafe(str) {
@@ -49,31 +61,50 @@ function urlSafe(str) {
 const sidebar = [];
 const panelRouter = [];
 
-Object.entries(nav).forEach(([category, entries]) => {
-	let base = `/settings/${urlSafe(category)}`;
+// Generate component tree from `nav` object once, as it won't change
+Object.entries(nav).forEach(([name, {Component, entries}]) => {
+	let base = `/settings/${urlSafe(name)}`;
+
+	let links = [];
+	let routes = [];
+
+	let firstRoute;
+
+	Object.entries(entries).forEach(([name, component]) => {
+		let url = `${base}/${urlSafe(name)}`;
+
+		if (firstRoute == undefined) {
+			firstRoute = `${base}/${urlSafe(name)}`;
+		}
+
+		routes.push([url, component]);
+
+		links.push(
+			<NavButton key={url} href={url} name={name} />
+		);
+	});
 
-	// Category header goes to first page in category
 	panelRouter.push(
 		<Route key={base} path={base}>
-			<Redirect to={`${base}/${urlSafe(entries[0][0])}`}/>
+			<Redirect to={firstRoute}/>
 		</Route>
 	);
 
-	let links = entries.map(([name, component]) => {
-		let url = `${base}/${urlSafe(name)}`;
-
-		panelRouter.push(
-			<Route key={url} path={url} component={component}/>
-		);
-
-		return <NavButton key={url} href={url} name={name} />;
-	});
+	let childrenPath = `${base}/:section`;
+	panelRouter.push(
+		<Route key={childrenPath} path={childrenPath}>
+			<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => {}}>
+				{/* FIXME: implement onReset */}
+				<Component routes={routes}/>
+			</ErrorBoundary>
+		</Route>
+	);
 
 	sidebar.push(
-		<React.Fragment key={category}>
-			<Link href={`${base}/${urlSafe(entries[0][0])}`}>
+		<React.Fragment key={name}>
+			<Link href={firstRoute}>
 				<a>
-					<h2>{category}</h2>
+					<h2>{name}</h2>
 				</a>
 			</Link>
 			<nav>
diff --git a/web/source/settings-panel/style.css b/web/source/settings-panel/style.css
index 36c7708f6..051038c58 100644
--- a/web/source/settings-panel/style.css
+++ b/web/source/settings-panel/style.css
@@ -129,6 +129,13 @@ input, select, textarea {
 
 .error {
 	font-weight: bold;
+
+	pre {
+		background: $bg;
+		padding: 1rem;
+		overflow: auto;
+		margin: 0;
+	}
 }
 
 .hidden {
diff --git a/web/source/settings-panel/user/index.js b/web/source/settings-panel/user/index.js
new file mode 100644
index 000000000..dddd3118c
--- /dev/null
+++ b/web/source/settings-panel/user/index.js
@@ -0,0 +1,51 @@
+/*
+   GoToSocial
+   Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+   This program is free software: you can redistribute it and/or modify
+   it under the terms of the GNU Affero General Public License as published by
+   the Free Software Foundation, either version 3 of the License, or
+   (at your option) any later version.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU Affero General Public License for more details.
+
+   You should have received a copy of the GNU Affero General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+"use strict";
+
+const Promise = require("bluebird");
+const React = require("react");
+const { Route, Switch } = require("wouter");
+
+module.exports = function UserPanel({oauth, routes}) {
+	// const [account, setAccount] = React.useState({});
+	// const [errorMsg, setError] = React.useState("");
+	// const [statusMsg, setStatus] = React.useState("Fetching user info");
+
+	// React.useEffect(() => {
+	// 	Promise.try(() => {
+	// 		return oauth.apiRequest("/api/v1/accounts/verify_credentials", "GET");
+	// 	}).then((json) => {
+	// 		setAccount(json);
+	// 	}).catch((e) => {
+	// 		setError(e.message);
+	// 		setStatus("");
+	// 	});
+	// }, [oauth, setAccount, setError, setStatus]);
+
+	// throw new Error("test");
+
+	return (
+		<Switch>
+			{routes.map(([path, component]) => {
+				console.log(component);
+				return <Route key={path} path={path} component={component}/>;
+			})}
+		</Switch>
+	);
+};
\ No newline at end of file
diff --git a/web/source/settings-panel/user/profile.js b/web/source/settings-panel/user/profile.js
index 517f60cdc..0127ca08b 100644
--- a/web/source/settings-panel/user/profile.js
+++ b/web/source/settings-panel/user/profile.js
@@ -18,6 +18,121 @@
 
 "use strict";
 
-module.exports = function UserProfile() {
-	return "user profile";
+const Promise = require("bluebird");
+const React = require("react");
+const { useErrorHandler } = require("react-error-boundary");
+
+const Submit = require("../components/submit");
+
+module.exports = function UserProfile({account, oauth}) {
+	const [errorMsg, setError] = React.useState("");
+	const [statusMsg, setStatus] = React.useState("");
+
+	const [headerFile, setHeaderFile] = React.useState(undefined);
+	const [headerSrc, setHeaderSrc] = React.useState("");
+
+	const [avatarFile, setAvatarFile] = React.useState(undefined);
+	const [avatarSrc, setAvatarSrc] = React.useState("");
+
+	const [displayName, setDisplayName] = React.useState("");
+	const [bio, setBio] = React.useState("");
+	const [locked, setLocked] = React.useState(false);
+
+	React.useEffect(() => {
+		setHeaderSrc(account.header);
+		setAvatarSrc(account.avatar);
+
+		setDisplayName(account.display_name);
+		setBio(account.source ? account.source.note : "");
+		setLocked(account.locked);
+	}, [account, setHeaderSrc, setAvatarSrc, setDisplayName, setBio, setLocked]);
+
+	const headerOnChange = (e) => {
+		setHeaderFile(e.target.files[0]);
+		setHeaderSrc(URL.createObjectURL(e.target.files[0]));
+	};
+
+	const avatarOnChange = (e) => {
+		setAvatarFile(e.target.files[0]);
+		setAvatarSrc(URL.createObjectURL(e.target.files[0]));
+	};
+
+	const submit = (e) => {
+		e.preventDefault();
+
+		setStatus("PATCHing");
+		setError("");
+		return Promise.try(() => {
+			let formDataInfo = new FormData();
+
+			if (headerFile) {
+				formDataInfo.set("header", headerFile);
+			}
+
+			if (avatarFile) {
+				formDataInfo.set("avatar", avatarFile);
+			}
+
+			formDataInfo.set("display_name", displayName);
+			formDataInfo.set("note", bio);
+			formDataInfo.set("locked", locked);
+
+			return oauth.apiRequest("/api/v1/accounts/update_credentials", "PATCH", formDataInfo, "form");
+		}).then((json) => {
+			setStatus("Saved!");
+
+			setHeaderSrc(json.header);
+			setAvatarSrc(json.avatar);
+
+			setDisplayName(json.display_name);
+			setBio(json.source.note);
+			setLocked(json.locked);
+		}).catch((e) => {
+			setError(e.message);
+			setStatus("");
+		});
+	};
+
+	return (
+		<section className="basic">
+			<h1>@{account.username}&apos;s Profile Info</h1>
+			<form>
+				<div className="labelinput">
+					<label htmlFor="header">Header</label>
+					<div className="border">
+						<img className="headerpreview" src={headerSrc} alt={headerSrc ? `header image for ${account.username}` : "None set"}/>
+						<div>
+							<label htmlFor="header" className="file-input button">Browse…</label>
+							<span>{headerFile ? headerFile.name : ""}</span>
+						</div>
+					</div>
+					<input className="hidden" id="header" type="file" accept="image/*" onChange={headerOnChange}/>
+				</div>
+				<div className="labelinput">
+					<label htmlFor="avatar">Avatar</label>
+					<div className="border">
+						<img className="avatarpreview" src={avatarSrc} alt={headerSrc ? `avatar image for ${account.username}` : "None set"}/>
+						<div>
+							<label htmlFor="avatar" className="file-input button">Browse…</label>
+							<span>{avatarFile ? avatarFile.name : ""}</span>
+						</div>
+					</div>
+					<input className="hidden" id="avatar" type="file" accept="image/*" onChange={avatarOnChange}/>
+				</div>
+				<div className="labelinput">
+					<label htmlFor="displayname">Display Name</label>
+					<input id="displayname" type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="A GoToSocial user"/>
+				</div>
+				<div className="labelinput">
+					<label htmlFor="bio">Bio</label>
+					<textarea id="bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."/>
+				</div>
+				<div className="labelcheckbox">
+					<label htmlFor="locked">Manually approve follow requests</label>
+					<input id="locked" type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)}/>
+				</div>
+				<Submit onClick={submit} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg}/>
+			</form>
+		</section>
+	);
 };
\ No newline at end of file
diff --git a/web/source/yarn.lock b/web/source/yarn.lock
index 41a2e6398..d213230b8 100644
--- a/web/source/yarn.lock
+++ b/web/source/yarn.lock
@@ -920,6 +920,13 @@
     "@babel/plugin-transform-react-jsx-development" "^7.18.6"
     "@babel/plugin-transform-react-pure-annotations" "^7.18.6"
 
+"@babel/runtime@^7.12.5":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
+  integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
 "@babel/runtime@^7.8.4":
   version "7.18.9"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a"
@@ -5078,6 +5085,13 @@ react-dom@^17.0.1:
     object-assign "^4.1.1"
     scheduler "^0.20.2"
 
+react-error-boundary@^3.1.4:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"
+  integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==
+  dependencies:
+    "@babel/runtime" "^7.12.5"
+
 react-is@^16.13.1:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"