Tracking your mood over time sounds simple until you try to build it: every entry has to stay private, the trends need real aggregation logic, and a journaling streak only matters if the math is correct. This guide walks through building a private mood tracker and journal where Strapi 5 owns the data layer and Next.js 16 handles the interface.
A quick note before we start: this is a technical build guide. The app is a personal tracking tool, not a substitute for professional mental health support. We're focused on the engineering, not the advice.
In brief:
- Strapi 5 provides the tools for user-scoped data isolation through custom policies, but developers must implement and attach these policies themselves—data isolation is not enforced by default.
- Custom services built on the Document Service API aggregate mood trends and calculate journaling streaks.
- You'll render rich journal entries with Strapi's Blocks editor and visualize trends with Recharts in Next.js 16.
- You'll wire authentication through JWT, the new
proxy.tsconvention, and a Data Access Layer for defense in depth.
What We're Building
The app is a private journaling and mood tracking tool. Users log a daily mood score on a 1-to-5 scale, attach optional contextual tags (sleep, exercise, social, work, nutrition, weather), and write a journal entry using a rich text editor. Strapi 5 stores everything (see the full feature set), scopes every query to the authenticated user, aggregates mood trends across 7-, 30-, and 90-day windows, and calculates journaling streaks through custom services.
Picture the user as someone keeping a private daily log they would never want exposed. That single requirement drives every architecture decision below. Because the data is sensitive, isolation cannot be an afterthought bolted onto the UI. It lives in the policy and controller layers where it cannot be bypassed by a crafted request.
Next.js 16 renders the interface. Server Actions handle mood logging and journal saves, Server Components fetch data directly, and Recharts draws the trend visualizations. Authentication runs on JSON Web Tokens (JWT) issued by Strapi's Users and Permissions plugin. While authenticated routes require a session, public pages are also supported and do not require a session by default.
This tutorial covers the engineering only. It does not offer mental health guidance, and the app should not be treated as a clinical tool.
What you'll learn:
- Defining MoodEntry and JournalEntry Content-Types with enumerations, Blocks fields, and user relations
- Enforcing per-user data isolation with custom Strapi policies on every route
- Aggregating mood data and calculating streaks with the Document Service API
- Building authenticated Next.js 16 pages with Server Actions and Recharts charts
Prerequisites
You'll need these pinned versions to match the code below:
- Node.js v24.x (recommended for Strapi 5.48.0) or any v20+
- Next.js 16.2.9 with React 19.2
- Recharts 3.8.1
- Tailwind CSS 4.3.1
- date-fns 4.4.0
- PostgreSQL 14.x or newer (Strapi 5 supports PostgreSQL from 14.0, with 17.0 recommended)
You should be comfortable with TypeScript, REST APIs, React Server Components, and basic SQL concepts. A code editor like VS Code rounds out the setup.
Set Up the Strapi Backend
Step 1: Install Strapi 5
Create the project with the official CLI.
npx create-strapi@latest mood-tracker-backendThe CLI prompts you through configuration. Choose TypeScript (the default), and select PostgreSQL when asked about the database. For local development you can start with SQLite, but production code here assumes PostgreSQL.
Configure your database connection in config/database.ts:
// config/database.ts
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', '127.0.0.1'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
schema: env('DATABASE_SCHEMA', 'public'),
ssl: env.bool('DATABASE_SSL', false) && {
rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
},
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10),
},
debug: false,
},
});The database user needs SCHEMA permissions. A user without them causes a 500 error when the Admin Panel loads. Start the server:
cd mood-tracker-backend
npm run developCreate your admin account when the Admin Panel opens. You can review the full installation guide for additional CLI flags.
Step 2: Define the MoodEntry and JournalEntry Content-Types
Two Collection Types back this app, and clean content modeling keeps their relationship predictable. Open the Content-Type Builder and create a MoodEntry Collection Type. Schema files live at src/api/[api-name]/content-types/[content-type-name]/schema.json. Here's the generated schema for MoodEntry:
{
"kind": "collectionType",
"collectionName": "mood_entries",
"info": {
"singularName": "mood-entry",
"pluralName": "mood-entries",
"displayName": "MoodEntry"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"mood": {
"type": "enumeration",
"enum": ["1", "2", "3", "4", "5"],
"required": true
},
"loggedAt": {
"type": "datetime",
"required": true
},
"tags": {
"type": "json"
},
"notes": {
"type": "text",
"maxLength": 280
},
"user": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}The mood field is an enumeration mapped to a 1-to-5 scale. Strapi 5 has no native multi-select type, so tags uses json to store an array of strings like ["sleep", "exercise"]. The user relation targets the Users and Permissions plugin user with the plugin::users-permissions.user format, a standard many-to-one relationship type.
Both Content-Types set draftAndPublish: false. A private journaling tool has no editorial workflow: an entry belongs to one person and is live the moment they save it, so a draft state would only add friction. This choice also simplifies queries. With draft and publish disabled, you never pass a status parameter, and every findMany() call returns the single canonical version of each entry rather than a draft and a published copy.
Create the JournalEntry Collection Type. Add a Rich Text (Blocks) field for content through the Content-Type Builder UI. The Blocks editor is the JSON-based rich text editor, distinct from the Markdown richtext type. Its schema type string is "blocks":
{
"kind": "collectionType",
"collectionName": "journal_entries",
"info": {
"singularName": "journal-entry",
"pluralName": "journal-entries",
"displayName": "JournalEntry"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"required": true
},
"content": {
"type": "blocks"
},
"date": {
"type": "date",
"required": true
},
"moodEntry": {
"type": "relation",
"relation": "oneToOne",
"target": "api::mood-entry.mood-entry"
},
"user": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}The Blocks editor stores content as structured JSON, an array of block nodes with type and children properties, not HTML. We'll render it on the frontend later.
Step 3: Build User-Scoped Policies
This is where privacy gets enforced, and the policy layer is the right place for it. A custom policy checks that the authenticated user owns the document before any handler runs. Policies live in src/api/[api-name]/policies/, and they attach to routes through route configuration.
The first argument is a policy context object, not a raw Koa ctx. Read auth state from policyContext.state. Create src/api/mood-entry/policies/is-owner.ts:
// src/api/mood-entry/policies/is-owner.ts
export default async (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user) {
return false;
}
const documentId = policyContext.params?.id;
if (!documentId) {
return true;
}
const entry = await strapi
.documents('api::mood-entry.mood-entry')
.findOne(documentId, {
populate: ['user'],
});
if (!entry) {
return false;
}
return entry.user?.id === user.id;
};Note the explicit populate: ['user']. Strapi 5 never uses populate: "*". Create the matching policy for journal entries at src/api/journal-entry/policies/is-owner.ts:
// src/api/journal-entry/policies/is-owner.ts
export default async (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user) {
return false;
}
const documentId = policyContext.params?.id;
if (!documentId) {
return true;
}
const entry = await strapi
.documents('api::journal-entry.journal-entry')
.findOne(documentId, {
populate: ['user'],
});
if (!entry) {
return false;
}
return entry.user?.id === user.id;
};Apply the mood-entry policy to every route. Strapi 5 generates a core router you can extend with config. Edit src/api/mood-entry/routes/mood-entry.ts:
// src/api/mood-entry/routes/mood-entry.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::mood-entry.mood-entry', {
config: {
find: { policies: ['api::mood-entry.is-owner'] },
findOne: { policies: ['api::mood-entry.is-owner'] },
create: { policies: ['api::mood-entry.is-owner'] },
update: { policies: ['api::mood-entry.is-owner'] },
delete: { policies: ['api::mood-entry.is-owner'] },
},
});The policy guards single-document routes by ownership. For find and create, the controller does the heavy lifting: it filters lists by user ID and assigns the user on creation. We override those next. Do the same for src/api/journal-entry/routes/journal-entry.ts using api::journal-entry.is-owner.
Two layers carry the load for a reason. The policy answers a single-document question: does this user own the record behind this documentId? That stops a crafted request such as GET /api/mood-entries/{id} carrying another user's JWT, where the attacker knows or guesses an ID. The controller answers a different question on list and create routes: which records may this user see, and who owns a new one? Filtering and assignment there mean a list response can never leak a stranger's entries.
To make find truly user-scoped, override the core controller at src/api/mood-entry/controllers/mood-entry.ts:
// src/api/mood-entry/controllers/mood-entry.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::mood-entry.mood-entry',
({ strapi }) => ({
async find(ctx) {
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized();
}
ctx.query = {
...ctx.query,
filters: {
...(ctx.query?.filters as object),
user: { id: user.id },
},
};
return super.find(ctx);
},
async create(ctx) {
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized();
}
ctx.request.body.data = {
...ctx.request.body.data,
user: user.id,
};
return super.create(ctx);
},
async getStreak(ctx) {
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized();
}
const streak = await strapi
.service('api::mood-entry.mood-entry')
.calculateStreak(user.id);
ctx.body = { data: { streak } };
},
async getMoodTrends(ctx) {
const user = ctx.state.user;
if (!user) {
return ctx.unauthorized();
}
const windowDays = Number(ctx.query.window ?? 30);
const tag = ctx.query.tag as string | undefined;
const trends = await strapi
.service('api::mood-entry.mood-entry')
.aggregateTrends(user.id, windowDays, tag);
const distribution = await strapi
.service('api::mood-entry.mood-entry')
.tagDistribution(user.id, windowDays);
ctx.body = { data: { trends, distribution } };
},
})
);This pattern guarantees a user only ever sees their own entries in list views, and every new entry is bound to the creator. Use the same approach in the journal-entry controller.
Step 4: Build the Mood Trend Aggregation Service
Strapi 5's Document Service API has no native GROUP BY for day or week aggregation. The approach is to query with findMany(), then group in JavaScript. If you want to compare how the same data surfaces over HTTP, the REST API documentation shows the flat response shape these queries produce. Add a service method at src/api/mood-entry/services/mood-entry.ts:
// src/api/mood-entry/services/mood-entry.ts
import { factories } from '@strapi/strapi';
type TrendPoint = { date: string; averageMood: number; count: number };
export default factories.createCoreService(
'api::mood-entry.mood-entry',
({ strapi }) => ({
async aggregateTrends(
userId: number,
windowDays: number,
tag?: string
): Promise<TrendPoint[]> {
const since = new Date();
since.setDate(since.getDate() - windowDays);
const filters: Record<string, unknown> = {
user: { id: userId },
loggedAt: { $gte: since.toISOString() },
};
const entries = await strapi
.documents('api::mood-entry.mood-entry')
.findMany({
filters,
sort: [{ loggedAt: 'asc' }],
fields: ['mood', 'loggedAt', 'tags'],
});
const filtered = tag
? entries.filter((e) => Array.isArray(e.tags) && e.tags.includes(tag))
: entries;
const buckets = new Map<string, { sum: number; count: number }>();
for (const entry of filtered) {
const day = new Date(entry.loggedAt).toISOString().slice(0, 10);
const score = Number(entry.mood);
const bucket = buckets.get(day) ?? { sum: 0, count: 0 };
bucket.sum += score;
bucket.count += 1;
buckets.set(day, bucket);
}
return Array.from(buckets.entries())
.map(([date, { sum, count }]) => ({
date,
averageMood: Math.round((sum / count) * 100) / 100,
count,
}))
.sort((a, b) => a.date.localeCompare(b.date));
},
async tagDistribution(userId: number, windowDays: number) {
const since = new Date();
since.setDate(since.getDate() - windowDays);
const entries = await strapi
.documents('api::mood-entry.mood-entry')
.findMany({
filters: {
user: { id: userId },
loggedAt: { $gte: since.toISOString() },
},
fields: ['mood', 'tags'],
});
const tagStats = new Map<string, { sum: number; count: number }>();
for (const entry of entries) {
if (!Array.isArray(entry.tags)) continue;
const score = Number(entry.mood);
for (const tag of entry.tags) {
const stat = tagStats.get(tag) ?? { sum: 0, count: 0 };
stat.sum += score;
stat.count += 1;
tagStats.set(tag, stat);
}
}
return Array.from(tagStats.entries()).map(([tag, { sum, count }]) => ({
tag,
averageMood: Math.round((sum / count) * 100) / 100,
count,
}));
},
async calculateStreak(userId: number) {
const entries = await strapi
.documents('api::mood-entry.mood-entry')
.findMany({
filters: { user: { id: userId } },
sort: [{ loggedAt: 'desc' }],
fields: ['loggedAt'],
});
const days = new Set<string>();
for (const entry of entries) {
days.add(new Date(entry.loggedAt).toISOString().slice(0, 10));
}
const sortedDays = Array.from(days).sort((a, b) =>
b.localeCompare(a)
);
if (sortedDays.length === 0) {
return { currentStreak: 0, longestStreak: 0 };
}
const dayMs = 86400000;
const toUtc = (d: string) => new Date(`${d}T00:00:00.000Z`).getTime();
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - dayMs)
.toISOString()
.slice(0, 10);
let currentStreak = 0;
if (sortedDays[0] === today || sortedDays[0] === yesterday) {
currentStreak = 1;
for (let i = 1; i < sortedDays.length; i++) {
const diff =
(toUtc(sortedDays[i - 1]) - toUtc(sortedDays[i])) / dayMs;
if (diff === 1) {
currentStreak += 1;
} else {
break;
}
}
}
let longestStreak = 1;
let run = 1;
for (let i = 1; i < sortedDays.length; i++) {
const diff = (toUtc(sortedDays[i - 1]) - toUtc(sortedDays[i])) / dayMs;
if (diff === 1) {
run += 1;
} else {
run = 1;
}
if (run > longestStreak) {
longestStreak = run;
}
}
return { currentStreak, longestStreak };
},
})
);The relation filter uses nested object syntax (user: { id: userId }), not dot notation. The date filter relies on the $gte operator. findMany() always returns an array in Strapi 5’s Document Service API; separately, Strapi 5 no longer uses the nested data.attributes response shape in the Content API. Grouping happens in JavaScript because the Document Service API exposes no day-level GROUP BY. For the data volumes a personal tracker produces, pulling a date-bounded slice and reducing it in memory stays fast and keeps the logic readable.
Step 5: Build the Streak Calculation Service
A streak counts consecutive days with at least one entry. Add calculateStreak to the same service file. Here's the method added to the services export object:
Walk through a quick example. Say a user logged entries on March 1, 2, 3, then skipped the 4th, then logged again on the 5th and 6th. The unique-day set collapses any duplicate same-day entries. Sorted descending, the algorithm counts back from today: if the most recent day is today or yesterday, it walks backward while each gap equals exactly one day. The longest streak scans the full history independently, so the March 1 to 3 run of three days would win if no later run is longer.
Expose the trends and streak through custom routes and controller actions. Custom route files load alphabetically, so prefix this one to load before the core router. Create src/api/mood-entry/routes/01-custom-mood-entry.ts:
// src/api/mood-entry/routes/01-custom-mood-entry.ts
export default {
routes: [
{
method: 'GET',
path: '/mood-entries/streak',
handler: 'api::mood-entry.mood-entry.getStreak',
config: {
policies: ['api::mood-entry.is-owner'],
},
},
{
method: 'GET',
path: '/mood-entries/trends',
handler: 'api::mood-entry.mood-entry.getMoodTrends',
config: {
policies: ['api::mood-entry.is-owner'],
},
},
],
};Add the matching actions to the controller. The handler string must match the controller filename. These actions live alongside the find and create overrides in the same controller object. Services are invoked with strapi.service('api::mood-entry.mood-entry'). Enable permissions for the Authenticated role: go to Settings → Users and Permissions plugin → Roles → Authenticated, expand Mood-entry and Journal-entry, and check create, find, findOne, update, and delete. Custom routes for a content-type do not automatically appear under the same content-type permission list; they require manual permission configuration in the admin panel.
Build the Next.js 16 Frontend
Step 1: Set Up the Next.js 16 Project
Create the frontend and install dependencies:
npx create-next-app@latest mood-tracker-frontend
cd mood-tracker-frontend
npm install @strapi/blocks-react-renderer recharts date-fnsPin your versions in package.json. Recharts still depends on react-is, and React 19 requires exact version matching, so add an override:
{
"dependencies": {
"next": "16.2.9",
"react": "19.2.7",
"react-dom": "19.2.7",
"recharts": "3.8.1",
"date-fns": "4.4.0",
"@strapi/blocks-react-renderer": "1.0.2"
},
"overrides": {
"react-is": "^19.0.0"
}
}Authentication runs on JWT issued by Strapi through the Users and Permissions plugin, a well-trodden approach to authentication in Next.js. Build a session module to create and verify the JWT stored in the cookie. Create app/lib/session.ts:
// app/lib/session.ts
import 'server-only';
import { cookies } from 'next/headers';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
export async function login(identifier: string, password: string) {
const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
if (!res.ok) {
throw new Error('Invalid credentials');
}
const data = await res.json();
const cookieStore = await cookies();
cookieStore.set('session', data.jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
return data.user;
}
export async function getToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get('session')?.value;
}The Strapi login endpoint is POST /api/auth/local, which returns a jwt and a user object. The cookie's httpOnly and secure settings are configurable. In the JWT/refresh token config, httpOnly defaults to false and secure defaults to false (set to true in production).
For a privacy-focused app these two flags matter. Setting httpOnly: true keeps the JWT out of reach of client-side JavaScript, which blunts token theft through cross-site scripting. Setting secure: true forces the cookie over HTTPS so it never travels in plaintext. You configure both in config/plugins.ts under the users-permissions plugin's jwt settings on the Strapi side, and the cookie options on the Next.js side mirror that intent. Treat the production values as non-negotiable.
Three layers guard the data, a layering that follows API security best practices. The proxy gives a fast first redirect, the Data Access Layer verifies the token against Strapi on every protected read, and Strapi's own policies make the final ownership decision.
Next.js 16 renamed middleware.ts to proxy.ts to clarify the network boundary. Create proxy.ts at the project root:
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
const publicRoutes = ['/login', '/signup'];
export default function proxy(req: NextRequest) {
const path = req.nextUrl.pathname;
const isPublicRoute = publicRoutes.includes(path);
const session = req.cookies.get('session')?.value;
if (!isPublicRoute && !session) {
return NextResponse.redirect(new URL('/login', req.url));
}
if (isPublicRoute && session) {
return NextResponse.redirect(new URL('/dashboard', req.url));
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};Proxy checks are a first pass, not your only defense. Next.js route protection should verify sessions close to the data source. Add a Data Access Layer at app/lib/dal.ts:
// app/lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { getToken } from './session';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
export const verifySession = cache(async () => {
const token = await getToken();
if (!token) {
redirect('/login');
}
const res = await fetch(`${STRAPI_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
redirect('/login');
}
const user = await res.json();
return { token, userId: user.id };
});React's cache memoizes the verification across a single render pass. Call verifySession() in Server Components, Server Actions, and Route Handlers.
Step 2: Build the Mood Logging Interface
The quick-log form lets a user pick a mood, toggle tags, and add an optional note. Start with the Server Action at app/actions/mood.ts:
'use server';
// app/actions/mood.ts
import { revalidatePath } from 'next/cache';
import { verifySession } from '@/app/lib/dal';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
export async function createMoodEntry(formData: FormData) {
const { token } = await verifySession();
const mood = formData.get('mood')?.toString();
const note = formData.get('note')?.toString() ?? '';
const tags = formData.getAll('tags').map((t) => t.toString());
if (!mood) {
throw new Error('Mood is required');
}
const res = await fetch(`${STRAPI_URL}/api/mood-entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
mood,
notes: note,
tags,
loggedAt: new Date().toISOString(),
},
}),
});
if (!res.ok) {
throw new Error('Failed to save mood entry');
}
const { data } = await res.json();
revalidatePath('/dashboard');
return data.documentId;
}The Server Action re-verifies the session before mutating. Client-side checks alone are not enough. The controller assigns the user automatically, so the request body never trusts a client-supplied user ID.
Build the form. Recharts and interactive forms need 'use client'. Create app/components/MoodLogger.tsx:
'use client';
// app/components/MoodLogger.tsx
import { useState } from 'react';
import { createMoodEntry } from '@/app/actions/mood';
const MOODS = [
{ value: '1', emoji: '😞', label: 'Awful' },
{ value: '2', emoji: '🙁', label: 'Bad' },
{ value: '3', emoji: '😐', label: 'Okay' },
{ value: '4', emoji: '🙂', label: 'Good' },
{ value: '5', emoji: '😄', label: 'Great' },
];
const TAGS = ['sleep', 'exercise', 'social', 'work', 'nutrition', 'weather'];
export default function MoodLogger() {
const [selectedMood, setSelectedMood] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
function toggleTag(tag: string) {
setSelectedTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
}
return (
<form action={createMoodEntry} className="space-y-4 rounded-lg border p-6">
<fieldset>
<legend className="mb-2 font-medium">How are you feeling?</legend>
<div className="flex gap-2">
{MOODS.map((m) => (
<label
key={m.value}
className={`cursor-pointer rounded-lg border px-4 py-3 text-center ${
selectedMood === m.value
? 'border-indigo-500 bg-indigo-50'
: 'border-neutral-200'
}`}
>
<input
type="radio"
name="mood"
value={m.value}
className="sr-only"
onChange={() => setSelectedMood(m.value)}
/>
<span className="block text-2xl">{m.emoji}</span>
<span className="text-xs">{m.label}</span>
</label>
))}
</div>
</fieldset>
<fieldset>
<legend className="mb-2 font-medium">Context</legend>
<div className="flex flex-wrap gap-2">
{TAGS.map((tag) => (
<label
key={tag}
className={`cursor-pointer rounded-full border px-3 py-1 text-sm ${
selectedTags.includes(tag)
? 'border-indigo-500 bg-indigo-50'
: 'border-neutral-200'
}`}
>
<input
type="checkbox"
name="tags"
value={tag}
className="sr-only"
checked={selectedTags.includes(tag)}
onChange={() => toggleTag(tag)}
/>
{tag}
</label>
))}
</div>
</fieldset>
<textarea
name="note"
rows={2}
maxLength={280}
placeholder="Optional note..."
className="w-full rounded-lg border p-2"
/>
<button
type="submit"
disabled={!selectedMood}
className="rounded-lg bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
>
Log mood
</button>
</form>
);
}The tags checkboxes share the name="tags", so formData.getAll('tags') returns every selected value as an array.
Step 3: Build the Journal Editor
Journal entries use the Blocks editor. To save content, build a Server Action that links the entry to a mood entry through the connect relation syntax. For a one-to-one or many-to-one relation, you can pass the documentId directly (shorthand format). Create app/actions/journal.ts:
'use server';
// app/actions/journal.ts
import { revalidatePath } from 'next/cache';
import { verifySession } from '@/app/lib/dal';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
type BlockNode = { type: string; children: { type: string; text: string }[] };
export async function createJournalEntry(
title: string,
text: string,
moodEntryDocumentId?: string
) {
const { token } = await verifySession();
const content: BlockNode[] = text
.split('\n\n')
.filter(Boolean)
.map((paragraph) => ({
type: 'paragraph',
children: [{ type: 'text', text: paragraph }],
}));
const res = await fetch(`${STRAPI_URL}/api/journal-entries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
data: {
title,
content,
date: new Date().toISOString().slice(0, 10),
...(moodEntryDocumentId
? { moodEntry: { connect: [moodEntryDocumentId] } }
: {}),
},
}),
});
if (!res.ok) {
throw new Error('Failed to save journal entry');
}
revalidatePath('/journal');
}Rendering the saved content requires the BlocksRenderer, which must run inside a Client Component in the App Router; this block editor guide covers the renderer in more depth. Create app/components/BlockRendererClient.tsx:
'use client';
// app/components/BlockRendererClient.tsx
import {
BlocksRenderer,
type BlocksContent,
} from '@strapi/blocks-react-renderer';
export default function BlockRendererClient({
content,
}: {
readonly content: BlocksContent;
}) {
if (!content) return null;
return (
<BlocksRenderer
content={content}
blocks={{
paragraph: ({ children }) => (
<p className="leading-relaxed text-neutral-900">{children}</p>
),
heading: ({ children, level }) => {
const tags = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4' } as const;
const Tag = tags[level] ?? 'h4';
return <Tag className="font-bold">{children}</Tag>;
},
list: ({ children, format }) =>
format === 'ordered' ? (
<ol className="ml-6 list-decimal">{children}</ol>
) : (
<ul className="ml-6 list-disc">{children}</ul>
),
}}
/>
);
}The children prop must always render to preserve nested blocks. Build the journal page itself, a Server Component that fetches entries and renders them. Create app/journal/page.tsx:
// app/journal/page.tsx
import { type BlocksContent } from '@strapi/blocks-react-renderer';
import { format } from 'date-fns';
import { verifySession } from '@/app/lib/dal';
import BlockRendererClient from '@/app/components/BlockRendererClient';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
type JournalEntry = {
documentId: string;
title: string;
date: string;
content: BlocksContent;
};
async function getJournalEntries(token: string): Promise<JournalEntry[]> {
const res = await fetch(
`${STRAPI_URL}/api/journal-entries?sort=date:desc`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
const { data } = await res.json();
return data;
}
export default async function JournalPage() {
const { token } = await verifySession();
const entries = await getJournalEntries(token);
return (
<main className="mx-auto max-w-2xl space-y-8 p-6">
<h1 className="text-2xl font-bold">Journal</h1>
{entries.map((entry) => (
<article key={entry.documentId} className="rounded-lg border p-6">
<header className="mb-3">
<h2 className="text-lg font-semibold">{entry.title}</h2>
<time className="text-sm text-neutral-500">
{format(new Date(entry.date), 'PPP')}
</time>
</header>
<BlockRendererClient content={entry.content} />
</article>
))}
</main>
);
}Step 4: Visualize Trends with Recharts
Recharts components use browser APIs and require 'use client'. Pass an explicit id to chart components to avoid a hydration mismatch on the generated clip IDs. Create app/components/MoodCharts.tsx:
'use client';
// app/components/MoodCharts.tsx
import {
LineChart,
Line,
BarChart,
Bar,
CartesianGrid,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
type TrendPoint = { date: string; averageMood: number };
type TagPoint = { tag: string; averageMood: number };
export function MoodLineChart({ data }: { data: TrendPoint[] }) {
return (
<ResponsiveContainer width="100%" height={280}>
<LineChart id="mood-line" data={data}>
<CartesianGrid stroke="#e0e0e0" strokeDasharray="5 5" />
<XAxis dataKey="date" />
<YAxis domain={[1, 5]} />
<Tooltip />
<Line
type="monotone"
dataKey="averageMood"
stroke="#6366f1"
dot={{ fill: '#fff' }}
/>
</LineChart>
</ResponsiveContainer>
);
}
export function MoodByTagChart({ data }: { data: TagPoint[] }) {
return (
<ResponsiveContainer width="100%" height={280}>
<BarChart id="mood-tag" data={data}>
<CartesianGrid stroke="#e0e0e0" strokeDasharray="5 5" />
<XAxis dataKey="tag" />
<YAxis domain={[0, 5]} />
<Tooltip />
<Bar dataKey="averageMood" fill="#6366f1" />
</BarChart>
</ResponsiveContainer>
);
}Two details keep these charts stable. Recharts 3.x still depends on react-is, and because React 19 demands exact version matching, the react-is override in package.json prevents a peer dependency conflict at install time. Recharts also generates internal clip-path IDs at render. When the server and client generate different IDs, React reports a hydration mismatch, so passing a fixed id to each chart pins those IDs to a known value on both sides.
A simple calendar heatmap shows logging consistency over the last several weeks. Add it as app/components/StreakHeatmap.tsx:
'use client';
// app/components/StreakHeatmap.tsx
import { eachDayOfInterval, subDays, format } from 'date-fns';
export default function StreakHeatmap({ loggedDays }: { loggedDays: string[] }) {
const logged = new Set(loggedDays);
const days = eachDayOfInterval({
start: subDays(new Date(), 90),
end: new Date(),
});
return (
<div className="flex flex-wrap gap-1">
{days.map((day) => {
const key = format(day, 'yyyy-MM-dd');
return (
<span
key={key}
title={key}
className={`h-3 w-3 rounded-sm ${
logged.has(key) ? 'bg-indigo-500' : 'bg-neutral-200'
}`}
/>
);
})}
</div>
);
}Assemble the dashboard as a Server Component that fetches trends and streak data, then passes them to the client charts. Create app/dashboard/page.tsx:
// app/dashboard/page.tsx
import { verifySession } from '@/app/lib/dal';
import MoodLogger from '@/app/components/MoodLogger';
import { MoodLineChart, MoodByTagChart } from '@/app/components/MoodCharts';
import StreakHeatmap from '@/app/components/StreakHeatmap';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
async function getData(token: string) {
const headers = { Authorization: `Bearer ${token}` };
const [trendsRes, streakRes, daysRes] = await Promise.all([
fetch(`${STRAPI_URL}/api/mood-entries/trends?window=30`, {
headers,
cache: 'no-store',
}),
fetch(`${STRAPI_URL}/api/mood-entries/streak`, {
headers,
cache: 'no-store',
}),
fetch(`${STRAPI_URL}/api/mood-entries?fields[0]=loggedAt&pagination[pageSize]=365`, {
headers,
cache: 'no-store',
}),
]);
const { data: trendData } = await trendsRes.json();
const { data: streak } = await streakRes.json();
const { data: entries } = await daysRes.json();
const loggedDays = entries.map((e: { loggedAt: string }) =>
e.loggedAt.slice(0, 10)
);
return { trends: trendData.trends, distribution: trendData.distribution, streak, loggedDays };
}
export default async function DashboardPage() {
const { token } = await verifySession();
const { trends, distribution, streak, loggedDays } = await getData(token);
return (
<main className="mx-auto max-w-3xl space-y-8 p-6">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<div className="flex gap-4 text-sm">
<span>
Current streak: <strong>{streak.currentStreak}</strong>
</span>
<span>
Longest: <strong>{streak.longestStreak}</strong>
</span>
</div>
</header>
<MoodLogger />
<section>
<h2 className="mb-2 font-semibold">Mood over time</h2>
<MoodLineChart data={trends} />
</section>
<section>
<h2 className="mb-2 font-semibold">Average mood by tag</h2>
<MoodByTagChart data={distribution} />
</section>
<section>
<h2 className="mb-2 font-semibold">Logging consistency</h2>
<StreakHeatmap loggedDays={loggedDays} />
</section>
</main>
);
}The dashboard fetches in parallel with Promise.all, then hands the data to client charts. Note the explicit field selection (fields[0]=loggedAt) on the consistency query to keep the payload lean.
Putting It All Together
Run both servers. Strapi on port 1337, Next.js on 3000. Walk through the full flow:
- Register a user through
POST /api/auth/local/register, then log in at/login. The session cookie is set, and the proxy redirects you to/dashboard. - Log a mood by selecting an emoji, toggling a few context tags, and submitting the form. The
createMoodEntryServer Action re-verifies the session, POSTs to/api/mood-entries, and the controller assigns your user ID automatically. The dashboard revalidates and shows the new entry. - Visit
/journaland write an entry. Pass the mood entry'sdocumentIdso thecreateJournalEntryaction links the two records through theconnectrelation syntax. The Blocks content saves as structured JSON. - Return to the dashboard. The line chart redraws with the new average mood, the average-mood-by-tag bar chart updates, the consistency heatmap fills another square, and the streak counter increments because today has an entry.
- Register a second user, log in, and try to read the first user's entry by hitting
GET /api/mood-entries/{documentId}with the second user's token. Theis-ownerpolicy compares the document'suserrelation againstpolicyContext.state.user.idand rejects the request if no matching owned content is found. List queries return only the second user's own entries because the controller filters everyfindby user ID.
That last step is the whole point. Data isolation is enforced at the controller and policy layers, not just hidden in the UI.
Next Steps
You have a working private mood tracker. A few directions to take it further:
- Deploy it. Push the Strapi backend to Strapi Cloud and the Next.js frontend to Vercel; weigh other deployment options if you prefer to self-host. The Strapi and Vercel integration covers connecting the two. Strapi Cloud offers a Free Plan with no credit card required.
- Add an export-my-data feature. Build a custom route at
GET /api/users/me/exportthat readsctx.state.user, runsfindMany()across both content-types filtered by user ID, and serializes the result to JSON. This is the GDPR data portability pattern. - Schedule reminders. Strapi 5 cron jobs run on
node-schedule. Register a job inbootstrapthat checks for users who haven't logged today and queues a notification. - Add journaling prompts. Store a small set of prompts in a Single Type and surface a random one on the journal page. You can also browse community plugins for ready-made extensions.
- Encrypt sensitive entries. Add a Document Service middleware to transform entry content before it hits the database.
The Strapi documentation and Next.js docs cover each of these in depth.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.