feat(boards): cache named board data with tag-based invalidation

Named boards are now served from Next.js unstable_cache (1h TTL,
tag `board-{name}`). A new `getSharedBoardWithWhereAsync` helper
fetches the board without per-user data so the result is safe to
share across requests.

Cache is busted via `invalidateBoardCacheAsync` (revalidateTag +
revalidatePath) from board save, layout save, and partial settings
mutations.
This commit is contained in:
Thomas Camlong committed 2026-05-18 14:51:44 +02:00
1 parent 2c388bd449
commit 22af9e2c3e
11 files changed
+146 -5

No files matched your search

@@ -6,4 +6,7 @@ export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() {
return await api.board.getHomeBoard();
},
getBoardName() {
return undefined;
},
});
@@ -6,4 +6,7 @@ export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
},
getBoardName({ name }) {
return name;
},
});
@@ -18,6 +18,7 @@ import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { getCachedBoardByName } from "../_cached-board";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
@@ -29,10 +30,12 @@ export type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoardAsync: (params: TParams) => Promise<Board>;
getBoardName: (params: TParams) => string | undefined;
}
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard,
getBoardName,
}: Props<TParams>) => {
return {
layout: createBoardLayout({
@@ -42,9 +45,13 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
// eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => {
const resolvedParams = await params;
const boardName = getBoardName(resolvedParams);
const queryClient = getQueryClient();
const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const [board, session] = await Promise.all([
boardName ? getCachedBoardByName(boardName) : getInitialBoard(resolvedParams),
auth(),
]);
const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind);
@@ -81,7 +88,12 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getInitialBoard(await params);
const resolvedParams = await params;
const boardName = getBoardName(resolvedParams);
const board = boardName
? await getCachedBoardByName(boardName)
: await getInitialBoard(resolvedParams);
const t = await getI18n();
return {
@@ -21,7 +21,6 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
@@ -30,6 +29,8 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { invalidateBoardCacheAsync } from "../_invalidate-board-cache";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -153,7 +154,7 @@ const EditModeMenu = () => {
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void revalidatePathActionAsync(`/boards/${board.name}`);
void invalidateBoardCacheAsync(board.name);
close();
},
onError() {
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache";
interface Props {
board: Board;
@@ -20,6 +21,7 @@ export const LayoutSettingsContent = ({ board }: Props) => {
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
void invalidateBoardCacheAsync(board.name);
},
});
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
@@ -1,6 +1,7 @@
import { clientApi } from "@homarr/api/client";
import type { Board } from "../../_types";
import { invalidateBoardCacheAsync } from "../../_invalidate-board-cache";
export const useSavePartialSettingsMutation = (board: Board) => {
const utils = clientApi.useUtils();
@@ -8,6 +9,7 @@ export const useSavePartialSettingsMutation = (board: Board) => {
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
void invalidateBoardCacheAsync(board.name);
},
});
};
@@ -0,0 +1,21 @@
import { unstable_cache } from "next/cache";
import { db, eq, sql } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { getSharedBoardWithWhereAsync } from "@homarr/api/router/board";
export function getCachedBoardByName(name: string) {
const tag = `board-${name.toLowerCase()}`;
return unstable_cache(
async () => {
const where = eq(sql`UPPER(${boards.name})`, name.toUpperCase());
return await getSharedBoardWithWhereAsync(db, where);
},
["board-by-name", name.toLowerCase()],
{
tags: [tag],
revalidate: 3600,
},
)();
}
@@ -0,0 +1,8 @@
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function invalidateBoardCacheAsync(boardName: string) {
revalidateTag(`board-${boardName.toLowerCase()}`);
revalidatePath(`/boards/${boardName}`, "page");
}