diff --git a/src/components/UserHistory.svelte b/src/components/UserHistory.svelte
new file mode 100644
index 0000000000000000000000000000000000000000..6ff874d7fc7988dc73e9336d9322acec60e8ba99
--- /dev/null
+++ b/src/components/UserHistory.svelte
@@ -0,0 +1,98 @@
+<script>
+	import ItemIcon from "./ItemIcon.svelte";
+	import IntersectionObserver from "svelte-intersection-observer";
+	import { onMount } from "svelte";
+	import * as devalue from "devalue";
+	
+	export let initialData, userId;
+	let transactions = initialData.transactions || [];
+	let hasMoreTransactions = initialData.hasMore ?? true;
+	let isFetching = false;
+	
+	function hasScrollBar() {
+		const rootElement = document.getElementById("main");
+		return rootElement.scrollHeight > rootElement.clientHeight;
+	}
+	
+	function fetchMoreTransactions(){
+		if(!hasMoreTransactions || isFetching) return;
+		isFetching = true;
+		fetch("/api/transactions?" + new URLSearchParams({
+			userId,
+			before: transactions[transactions.length-1].createdAt.getTime(),
+			balance: transactions[transactions.length-1].balance-transactions[transactions.length-1].difference
+		}), {
+			method: "GET"
+		}).then(r=>r.text()).then(devalue.parse).then(({transactions: prevTransactions, hasMore})=>{
+			if(prevTransactions.length > 0) transactions = [...transactions, ...prevTransactions];
+			hasMoreTransactions = hasMore;
+			isFetching = false;
+			if(!hasScrollBar()) fetchMoreTransactions();
+		});
+	}
+	
+	onMount(()=>hasScrollBar()||fetchMoreTransactions());
+	
+	let observerElement;
+</script>
+
+<style>
+	.gain {
+		color: green;
+	}
+	.loss {
+		color: red;
+	}
+	table {
+		width: 100%;
+		margin-bottom: .5rem;
+		margin-top: 1rem;
+	}
+	tr:nth-child(1) {
+		position: sticky;
+		top: 0;
+		z-index: 10;
+	}
+	td:not(:nth-child(2)) {
+		text-align: right;
+	}
+	td:nth-child(1) {
+		display: flex;
+		justify-content: center;
+		align-items: center;
+	}
+	th > span, td > span {
+		display: inline-block;
+		padding: 0 1.5em;
+	}
+</style>
+
+<!-- TODO add way to navigate back -->
+
+<table>
+	<tr>
+		<th></th>
+		<th><span>Aktion</span></th>
+		<th><span>Preis</span></th>
+		<th><span>Guthaben</span></th>
+		<th><span>Datum</span></th>
+	</tr>
+	{#each transactions as transaction}
+		<tr>
+			<td>{#if transaction.type==="item"}<ItemIcon icon={transaction.image} size="40px" name={transaction.name} />{/if}</td>
+			<td><span>{transaction.name}</span></td>
+			<td class="{transaction.difference > 0 ? "gain" : transaction.difference < 0 ? "loss" : ""}"><span>{#if transaction.difference>0}+{/if}{(transaction.difference/100).toFixed(2)}€</span></td> <!-- TODO show premium on item transcations -->
+			<td><span>{(transaction.balance/100).toFixed(2)}€</span></td>
+			<td><span>{transaction.createdAt.toLocaleString()}</span></td>
+		</tr>
+	{/each}
+</table>
+{#if transactions.length === 0}
+	<p>Keine Transaktionen gefunden.</p>
+{:else}
+	<IntersectionObserver element={observerElement} rootMargin="100px 0px 0px 0px" on:intersect={fetchMoreTransactions}>
+		<div bind:this={observerElement} style="margin-bottom: 50px;height:1em;{hasMoreTransactions ? "" : "visibility: hidden;"}">
+			{#if isFetching}Lade weitere Transaktionen...{/if}
+		</div>
+	</IntersectionObserver>
+{/if}
diff --git a/src/routes/api/[slug]/+server.js b/src/routes/api/[slug]/+server.js
index c49ead2686aff7320b627bab8d1876c3a8a67fc6..d8362ed8896e6c0fffb0dd3c1ff4b492065ad02d 100644
--- a/src/routes/api/[slug]/+server.js
+++ b/src/routes/api/[slug]/+server.js
@@ -1,7 +1,8 @@
+import * as devalue from "devalue";
 import { getPriceModifier } from "../../admin/api/articles";
 import { Flags } from "$lib/flags";
 import { getArticles, getBuyableVouchers } from "../articles";
-import { buyArticles, transferMoney, useVoucher } from "../transactions";
+import { buyArticles, fetchTransactions, transferMoney, useVoucher } from "../transactions";
 import { getUserById } from "../users";
 import { Codes } from "$lib/customcodes";
 import { AllOrNothingRateLimiter, SlidingWindowRateLimiter } from "../../../lib/ratelimit";
@@ -112,7 +113,30 @@ export async function GET(req){
 	switch(req.params.slug){
 		case "items":
 			return getArticles().then(articles => new Response(JSON.stringify(articles), {status: 200}));
+		case "transactions": {
+			try {
+				const userId = parseInt(req.url.searchParams.get("userId"));
+				const balance = parseInt(req.url.searchParams.get("balance"));
+				const before = parseInt(req.url.searchParams.get("before"));
+				if (isNaN(userId) || isNaN(balance) || isNaN(before)) {
+					return new Response(JSON.stringify({ message: "Invalid fields" }), { status: 400 });
+				}
+				if(userId !== req.locals.user.id && !(req.locals.user.perms & Flags.ADMIN)){
+					return new Response(JSON.stringify({ message: "You can't view other users' transactions" }), { status: 403 });
+				}
+				try {
+					const transactions = await fetchTransactions(userId, balance, before, 100);
+					// use devalue because JSON.stringify/JSON.parse doesn't really support Date
+					return new Response(devalue.stringify(transactions), { status: 200 });
+				} catch (error) {
+					console.error(error);
+					return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500 });
+				}
+			} catch (e) {
+				return new Response(JSON.stringify({ message: "Invalid data" }), { headers: { 'content-type': 'application/json', 'status': 400 } });
+			}
+		}
 		default:
-			return "";
+			return new Response("Unknown endpoint", {status: 400});
 	}
 }
diff --git a/src/routes/api/transactions.js b/src/routes/api/transactions.js
index 878835565ae31f64e1ca314520dfc9ed4e443012..850ec9015f396a111a6ec621949e2a36dd3ced30 100644
--- a/src/routes/api/transactions.js
+++ b/src/routes/api/transactions.js
@@ -127,3 +127,63 @@ export async function getUsedVouchers(usedById, before=Date.now(), limit=100) {
 		orderBy: { usedAt: "desc" }
 	});
 }
+
+// TODO somehow fix this
+// this skips transactions if `before` is the same as the createdAt date of the previous transaction
+// e.g. you could sort by id instead of createdAt and provide the last id of each transaction type as
+// replacement for `before`
+export async function fetchTransactions(userId, balanceAfterwards, before, limit) {
+	/*
+	For every type of transaction that changes the user's balance the limit of transactions to return
+	is used. This means at most we fetch 4*limit transactions from the database.
+	Since we don't know how many transactions of each type are within the last [limit] transactions
+	we have to fetch all of them and then sort them by date. Afterwards we only take the first [limit]
+	transactions. We can't take more than [limit] transactions because we don't know if we have fetched
+	the correct next transaction.
+	
+	(That is not entirely true. We could return transactions until we have [limit] of one type. We
+	could do that, but I don't think it's worth it and it would mean that the function gets a
+	"limit" parameter and returns more than [limit] transactions. I'd rather have data be fetched
+	multiple times but have the function behave as expected.)
+	*/
+	/*
+	[limit]+1 items are fetched because then we can check if there are more items to fetch.
+	*/
+	const moneyTransactions = (await getMoneyTransactions(userId, before, limit+1)).map(transaction => ({
+		type: "transaction",
+		name: transaction.amount > 0 ? "Aufladen" : "Auszahlung",
+		difference: transaction.amount,
+		createdAt: transaction.createdAt,
+		verified: !!transaction.verifiedById
+	}));
+	const itemTransactions = (await getItemTransactions(userId, before, limit+1)).map(transaction => ({
+		type: "item",
+		name: transaction.item.name,
+		difference: -(transaction.price + transaction.premium),
+		premium: transaction.premium,
+		createdAt: transaction.createdAt,
+		image: transaction.item.image,
+	}));
+	const moneyTransfers = (await getMoneyTransfers(userId, before, limit+1)).map(transfer => ({
+		type: "transfer",
+		name: transfer.fromId === userId ? `Gesendet an ${transfer.to.name}` : `Empfangen von ${transfer.from.name}`,
+		difference: transfer.fromId === userId ? -transfer.amount : transfer.amount,
+		createdAt: transfer.createdAt
+	}));
+	const voucherUses = (await getUsedVouchers(userId, before, limit+1)).map(voucher => ({
+		type: "voucher",
+		name: `Gutschein ${voucher.code} eingelöst`,
+		difference: voucher.amount,
+		createdAt: voucher.usedAt // use usedAt because voucher creation time is not relevant
+	}));
+	let transactions = [...moneyTransactions, ...itemTransactions, ...moneyTransfers, ...voucherUses]
+			.sort((a, b) => b.createdAt - a.createdAt);
+	const hasMore = transactions.length > limit;
+	if(transactions.length > limit) transactions = transactions.filter((_,i)=>i<limit);
+	if (transactions.length > 0) {
+		transactions[0].balance = balanceAfterwards;
+		for (let i = 1; i < transactions.length; i++)
+			transactions[i].balance = transactions[i - 1].balance - transactions[i - 1].difference;
+	}
+	return {transactions, hasMore};
+}
diff --git a/src/routes/history/+page.server.js b/src/routes/history/+page.server.js
index 1aeb8cc01610d13fdf9971e5e1947c4ad758f013..6a1fe6a7e9ff92b2fb1fe46b5eca8cee275b3b24 100644
--- a/src/routes/history/+page.server.js
+++ b/src/routes/history/+page.server.js
@@ -1,76 +1,8 @@
-import { getMoneyTransactions, getItemTransactions, getMoneyTransfers, getUsedVouchers } from "../api/transactions";
-
-async function fetchData(userId, balanceAfterwards, before, limit) {
-	/*
-	For every type of transaction that changes the user's balance the limit of transactions to return
-	is used. This means at most we fetch 3*limit transactions from the database.
-	Since we don't know how many transactions of each type are within the last [limit] transactions
-	we have to fetch all of them and then sort them by date. Afterwards we only take the first [limit]
-	transactions. We can't take more than [limit] transactions because we don't know if we have fetched
-	the correct next transaction.
-	
-	(That is not entirely true. We could return transactions until we have [limit] of one type. We
-	could do that, but I don't think it's worth it and it would mean that the function gets a
-	"limit" parameter and returns more than [limit] transactions. I'd rather have data be fetched
-	multiple times but have the function behave as expected.)
-	*/
-	/*
-	[limit]+1 items are fetched because then we can check if there are more items to fetch.
-	*/
-	const moneyTransactions = (await getMoneyTransactions(userId, before, limit+1)).map(transaction => ({
-		type: "transaction",
-		name: transaction.amount > 0 ? "Aufladen" : "Auszahlung",
-		difference: transaction.amount,
-		createdAt: transaction.createdAt,
-		verified: !!transaction.verifiedById
-	}));
-	const itemTransactions = (await getItemTransactions(userId, before, limit+1)).map(transaction => ({
-		type: "item",
-		name: transaction.item.name,
-		difference: -(transaction.price + transaction.premium),
-		premium: transaction.premium,
-		createdAt: transaction.createdAt,
-		image: transaction.item.image,
-	}));
-	const moneyTransfers = (await getMoneyTransfers(userId, before, limit+1)).map(transfer => ({
-		type: "transfer",
-		name: transfer.fromId === userId ? `Gesendet an ${transfer.to.name}` : `Empfangen von ${transfer.from.name}`,
-		difference: transfer.fromId === userId ? -transfer.amount : transfer.amount,
-		createdAt: transfer.createdAt
-	}));
-	const voucherUses = (await getUsedVouchers(userId, before, limit+1)).map(voucher => ({
-		type: "voucher",
-		name: `Gutschein ${voucher.code} eingelöst`,
-		difference: voucher.amount,
-		createdAt: voucher.usedAt // use usedAt because voucher creation time is not relevant
-	}));
-	let transactions = [...moneyTransactions, ...itemTransactions, ...moneyTransfers, ...voucherUses]
-			.sort((a, b) => b.createdAt - a.createdAt);
-	const hasMore = transactions.length > limit;
-	if(transactions.length > limit) transactions = transactions.filter((_,i)=>i<limit);
-	if (transactions.length > 0) {
-		transactions[0].balance = balanceAfterwards;
-		for (let i = 1; i < transactions.length; i++)
-			transactions[i].balance = transactions[i - 1].balance - transactions[i - 1].difference;
-	}
-	return {transactions, hasMore};
-}
+import { fetchTransactions } from "../api/transactions";
 
 export async function load(event) {
 	const userId = event.locals.user.id;
 	const balance = event.locals.user.balance;
-	const {transactions, hasMore} = await fetchData(userId, balance, Date.now(), 100);
-	return { transactions, hasMore };
+	const {transactions, hasMore} = await fetchTransactions(userId, balance, Date.now(), 100);
+	return { transactions, hasMore, userId };
 };
-
-export const actions = {
-	transactions: async event=>{
-		const data = await event.request.formData();
-		const userId = event.locals.user.id;
-		const balance = parseInt(data.get("balance"));
-		const before = parseInt(data.get("before"));
-		if(!Number.isInteger(balance) || !Number.isInteger(before)) return { error: "Invalid data" };
-		const {transactions, hasMore} = await fetchData(userId, balance, before, 100);
-		return {transactions, hasMore};
-	}
-}
diff --git a/src/routes/history/+page.svelte b/src/routes/history/+page.svelte
index 679bfe3ddd90033032e2458ceceb889f19b375dc..ede05ee94c5af52b380869a5a719b7ff03d5c634 100644
--- a/src/routes/history/+page.svelte
+++ b/src/routes/history/+page.svelte
@@ -1,98 +1,7 @@
 <script>
-	import ItemIcon from '../../components/ItemIcon.svelte';
-	import IntersectionObserver from "svelte-intersection-observer";
-	import { deserialize } from "$app/forms";
-	import { onMount } from "svelte";
+	import UserHistory from "../../components/UserHistory.svelte";
 	
 	export let data;
-	let transactions = data.transactions || [];
-	let hasMoreTransactions = data.hasMore;
-	let isFetching = false;
-	
-	function hasScrollBar() {
-		const rootElement = document.getElementById("main");
-		return rootElement.scrollHeight > rootElement.clientHeight;
-	}
-	
-	function fetchMoreTransactions(){
-		if(!hasMoreTransactions || isFetching) return;
-		isFetching = true;
-		const fd = new FormData();
-		fd.append("before", transactions[transactions.length-1].createdAt.getTime());
-		fd.append("balance", transactions[transactions.length-1].balance-transactions[transactions.length-1].difference);
-		fetch("?/transactions", {
-			method: "POST",
-			body: fd
-		}).then(r=>r.text()).then(deserialize).then(({data: {transactions: prevTransactions, hasMore}}) => {
-			if(prevTransactions.length > 0) transactions = [...transactions, ...prevTransactions];
-			hasMoreTransactions = hasMore;
-			isFetching = false;
-			if(!hasScrollBar()) fetchMoreTransactions();
-		});
-	}
-	
-	onMount(()=>hasScrollBar()||fetchMoreTransactions());
-	
-	let observerElement;
 </script>
 
-<style>
-	.gain {
-		color: green;
-	}
-	.loss {
-		color: red;
-	}
-	table {
-		width: 100%;
-		margin-bottom: .5rem;
-		margin-top: 1rem;
-	}
-	tr:nth-child(1) {
-		position: sticky;
-		top: 0;
-		z-index: 10;
-	}
-	td:not(:nth-child(2)) {
-		text-align: right;
-	}
-	td:nth-child(1) {
-		display: flex;
-		justify-content: center;
-		align-items: center;
-	}
-	th > span, td > span {
-		display: inline-block;
-		padding: 0 1.5em;
-	}
-</style>
-
-<!-- TODO add way to navigate back -->
-
-<table>
-	<tr>
-		<th></th>
-		<th><span>Aktion</span></th>
-		<th><span>Preis</span></th>
-		<th><span>Guthaben</span></th>
-		<th><span>Datum</span></th>
-	</tr>
-	{#each transactions as transaction}
-		<tr>
-			<td>{#if transaction.type==="item"}<ItemIcon icon={transaction.image} size="40px" name={transaction.name} />{/if}</td>
-			<td><span>{transaction.name}</span></td>
-			<td class="{transaction.difference > 0 ? "gain" : transaction.difference < 0 ? "loss" : ""}"><span>{#if transaction.difference>0}+{/if}{(transaction.difference/100).toFixed(2)}€</span></td> <!-- TODO show premium on item transcations -->
-			<td><span>{(transaction.balance/100).toFixed(2)}€</span></td>
-			<td><span>{transaction.createdAt.toLocaleString()}</span></td>
-		</tr>
-	{/each}
-</table>
-{#if transactions.length === 0}
-	<p>Keine Transaktionen gefunden.</p>
-{:else}
-	<IntersectionObserver element={observerElement} rootMargin="100px 0px 0px 0px" on:intersect={fetchMoreTransactions}>
-		<div bind:this={observerElement} style="margin-bottom: 50px;height:1em;{hasMoreTransactions ? "" : "visibility: hidden;"}">
-			{#if isFetching}Lade weitere Transaktionen...{/if}
-		</div>
-	</IntersectionObserver>
-{/if}
+<UserHistory initialData={data} userId={data.userId} />