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:
proxy.ts convention, and a Data Access Layer for defense in depth.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:
You'll need these pinned versions to match the code below:
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.
Create the project with the official CLI.
1npx 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:
1// config/database.ts
2export default ({ env }) => ({
3 connection: {
4 client: 'postgres',
5 connection: {
6 connectionString: env('DATABASE_URL'),
7 host: env('DATABASE_HOST', '127.0.0.1'),
8 port: env.int('DATABASE_PORT', 5432),
9 database: env('DATABASE_NAME', 'strapi'),
10 user: env('DATABASE_USERNAME', 'strapi'),
11 password: env('DATABASE_PASSWORD', 'strapi'),
12 schema: env('DATABASE_SCHEMA', 'public'),
13 ssl: env.bool('DATABASE_SSL', false) && {
14 rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
15 },
16 },
17 pool: {
18 min: env.int('DATABASE_POOL_MIN', 2),
19 max: env.int('DATABASE_POOL_MAX', 10),
20 },
21 debug: false,
22 },
23});The database user needs SCHEMA permissions. A user without them causes a 500 error when the Admin Panel loads. Start the server:
1cd mood-tracker-backend
2npm run developCreate your admin account when the Admin Panel opens. You can review the full installation guide for additional CLI flags.
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:
1{
2 "kind": "collectionType",
3 "collectionName": "mood_entries",
4 "info": {
5 "singularName": "mood-entry",
6 "pluralName": "mood-entries",
7 "displayName": "MoodEntry"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "mood": {
14 "type": "enumeration",
15 "enum": ["1", "2", "3", "4", "5"],
16 "required": true
17 },
18 "loggedAt": {
19 "type": "datetime",
20 "required": true
21 },
22 "tags": {
23 "type": "json"
24 },
25 "notes": {
26 "type": "text",
27 "maxLength": 280
28 },
29 "user": {
30 "type": "relation",
31 "relation": "manyToOne",
32 "target": "plugin::users-permissions.user"
33 }
34 }
35}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":
1{
2 "kind": "collectionType",
3 "collectionName": "journal_entries",
4 "info": {
5 "singularName": "journal-entry",
6 "pluralName": "journal-entries",
7 "displayName": "JournalEntry"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "title": {
14 "type": "string",
15 "minLength": 1,
16 "maxLength": 120,
17 "required": true
18 },
19 "content": {
20 "type": "blocks"
21 },
22 "date": {
23 "type": "date",
24 "required": true
25 },
26 "moodEntry": {
27 "type": "relation",
28 "relation": "oneToOne",
29 "target": "api::mood-entry.mood-entry"
30 },
31 "user": {
32 "type": "relation",
33 "relation": "manyToOne",
34 "target": "plugin::users-permissions.user"
35 }
36 }
37}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.
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:
1// src/api/mood-entry/policies/is-owner.ts
2export default async (policyContext, config, { strapi }) => {
3 const user = policyContext.state.user;
4 if (!user) {
5 return false;
6 }
7
8 const documentId = policyContext.params?.id;
9
10 if (!documentId) {
11 return true;
12 }
13
14 const entry = await strapi
15 .documents('api::mood-entry.mood-entry')
16 .findOne(documentId, {
17 populate: ['user'],
18 });
19
20 if (!entry) {
21 return false;
22 }
23
24 return entry.user?.id === user.id;
25};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:
1// src/api/journal-entry/policies/is-owner.ts
2export default async (policyContext, config, { strapi }) => {
3 const user = policyContext.state.user;
4 if (!user) {
5 return false;
6 }
7
8 const documentId = policyContext.params?.id;
9 if (!documentId) {
10 return true;
11 }
12
13 const entry = await strapi
14 .documents('api::journal-entry.journal-entry')
15 .findOne(documentId, {
16 populate: ['user'],
17 });
18
19 if (!entry) {
20 return false;
21 }
22
23 return entry.user?.id === user.id;
24};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:
1// src/api/mood-entry/routes/mood-entry.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreRouter('api::mood-entry.mood-entry', {
5 config: {
6 find: { policies: ['api::mood-entry.is-owner'] },
7 findOne: { policies: ['api::mood-entry.is-owner'] },
8 create: { policies: ['api::mood-entry.is-owner'] },
9 update: { policies: ['api::mood-entry.is-owner'] },
10 delete: { policies: ['api::mood-entry.is-owner'] },
11 },
12});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:
1// src/api/mood-entry/controllers/mood-entry.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController(
5 'api::mood-entry.mood-entry',
6 ({ strapi }) => ({
7 async find(ctx) {
8 const user = ctx.state.user;
9 if (!user) {
10 return ctx.unauthorized();
11 }
12
13 ctx.query = {
14 ...ctx.query,
15 filters: {
16 ...(ctx.query?.filters as object),
17 user: { id: user.id },
18 },
19 };
20
21 return super.find(ctx);
22 },
23
24 async create(ctx) {
25 const user = ctx.state.user;
26 if (!user) {
27 return ctx.unauthorized();
28 }
29
30 ctx.request.body.data = {
31 ...ctx.request.body.data,
32 user: user.id,
33 };
34
35 return super.create(ctx);
36 },
37
38 async getStreak(ctx) {
39 const user = ctx.state.user;
40 if (!user) {
41 return ctx.unauthorized();
42 }
43
44 const streak = await strapi
45 .service('api::mood-entry.mood-entry')
46 .calculateStreak(user.id);
47
48 ctx.body = { data: { streak } };
49 },
50
51 async getMoodTrends(ctx) {
52 const user = ctx.state.user;
53 if (!user) {
54 return ctx.unauthorized();
55 }
56
57 const windowDays = Number(ctx.query.window ?? 30);
58 const tag = ctx.query.tag as string | undefined;
59
60 const trends = await strapi
61 .service('api::mood-entry.mood-entry')
62 .aggregateTrends(user.id, windowDays, tag);
63
64 const distribution = await strapi
65 .service('api::mood-entry.mood-entry')
66 .tagDistribution(user.id, windowDays);
67
68 ctx.body = { data: { trends, distribution } };
69 },
70 })
71);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.
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:
1// src/api/mood-entry/services/mood-entry.ts
2import { factories } from '@strapi/strapi';
3
4type TrendPoint = { date: string; averageMood: number; count: number };
5
6export default factories.createCoreService(
7 'api::mood-entry.mood-entry',
8 ({ strapi }) => ({
9 async aggregateTrends(
10 userId: number,
11 windowDays: number,
12 tag?: string
13 ): Promise<TrendPoint[]> {
14 const since = new Date();
15 since.setDate(since.getDate() - windowDays);
16
17 const filters: Record<string, unknown> = {
18 user: { id: userId },
19 loggedAt: { $gte: since.toISOString() },
20 };
21
22 const entries = await strapi
23 .documents('api::mood-entry.mood-entry')
24 .findMany({
25 filters,
26 sort: [{ loggedAt: 'asc' }],
27 fields: ['mood', 'loggedAt', 'tags'],
28 });
29
30 const filtered = tag
31 ? entries.filter((e) => Array.isArray(e.tags) && e.tags.includes(tag))
32 : entries;
33
34 const buckets = new Map<string, { sum: number; count: number }>();
35
36 for (const entry of filtered) {
37 const day = new Date(entry.loggedAt).toISOString().slice(0, 10);
38 const score = Number(entry.mood);
39 const bucket = buckets.get(day) ?? { sum: 0, count: 0 };
40 bucket.sum += score;
41 bucket.count += 1;
42 buckets.set(day, bucket);
43 }
44
45 return Array.from(buckets.entries())
46 .map(([date, { sum, count }]) => ({
47 date,
48 averageMood: Math.round((sum / count) * 100) / 100,
49 count,
50 }))
51 .sort((a, b) => a.date.localeCompare(b.date));
52 },
53
54 async tagDistribution(userId: number, windowDays: number) {
55 const since = new Date();
56 since.setDate(since.getDate() - windowDays);
57
58 const entries = await strapi
59 .documents('api::mood-entry.mood-entry')
60 .findMany({
61 filters: {
62 user: { id: userId },
63 loggedAt: { $gte: since.toISOString() },
64 },
65 fields: ['mood', 'tags'],
66 });
67
68 const tagStats = new Map<string, { sum: number; count: number }>();
69
70 for (const entry of entries) {
71 if (!Array.isArray(entry.tags)) continue;
72 const score = Number(entry.mood);
73 for (const tag of entry.tags) {
74 const stat = tagStats.get(tag) ?? { sum: 0, count: 0 };
75 stat.sum += score;
76 stat.count += 1;
77 tagStats.set(tag, stat);
78 }
79 }
80
81 return Array.from(tagStats.entries()).map(([tag, { sum, count }]) => ({
82 tag,
83 averageMood: Math.round((sum / count) * 100) / 100,
84 count,
85 }));
86 },
87
88 async calculateStreak(userId: number) {
89 const entries = await strapi
90 .documents('api::mood-entry.mood-entry')
91 .findMany({
92 filters: { user: { id: userId } },
93 sort: [{ loggedAt: 'desc' }],
94 fields: ['loggedAt'],
95 });
96
97 const days = new Set<string>();
98 for (const entry of entries) {
99 days.add(new Date(entry.loggedAt).toISOString().slice(0, 10));
100 }
101
102 const sortedDays = Array.from(days).sort((a, b) =>
103 b.localeCompare(a)
104 );
105
106 if (sortedDays.length === 0) {
107 return { currentStreak: 0, longestStreak: 0 };
108 }
109
110 const dayMs = 86400000;
111 const toUtc = (d: string) => new Date(`${d}T00:00:00.000Z`).getTime();
112
113 const today = new Date().toISOString().slice(0, 10);
114 const yesterday = new Date(Date.now() - dayMs)
115 .toISOString()
116 .slice(0, 10);
117
118 let currentStreak = 0;
119 if (sortedDays[0] === today || sortedDays[0] === yesterday) {
120 currentStreak = 1;
121 for (let i = 1; i < sortedDays.length; i++) {
122 const diff =
123 (toUtc(sortedDays[i - 1]) - toUtc(sortedDays[i])) / dayMs;
124 if (diff === 1) {
125 currentStreak += 1;
126 } else {
127 break;
128 }
129 }
130 }
131
132 let longestStreak = 1;
133 let run = 1;
134 for (let i = 1; i < sortedDays.length; i++) {
135 const diff = (toUtc(sortedDays[i - 1]) - toUtc(sortedDays[i])) / dayMs;
136 if (diff === 1) {
137 run += 1;
138 } else {
139 run = 1;
140 }
141 if (run > longestStreak) {
142 longestStreak = run;
143 }
144 }
145
146 return { currentStreak, longestStreak };
147 },
148 })
149);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.
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:
1// src/api/mood-entry/routes/01-custom-mood-entry.ts
2export default {
3 routes: [
4 {
5 method: 'GET',
6 path: '/mood-entries/streak',
7 handler: 'api::mood-entry.mood-entry.getStreak',
8 config: {
9 policies: ['api::mood-entry.is-owner'],
10 },
11 },
12 {
13 method: 'GET',
14 path: '/mood-entries/trends',
15 handler: 'api::mood-entry.mood-entry.getMoodTrends',
16 config: {
17 policies: ['api::mood-entry.is-owner'],
18 },
19 },
20 ],
21};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.
Create the frontend and install dependencies:
1npx create-next-app@latest mood-tracker-frontend
2cd mood-tracker-frontend
3npm 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:
1{
2 "dependencies": {
3 "next": "16.2.9",
4 "react": "19.2.7",
5 "react-dom": "19.2.7",
6 "recharts": "3.8.1",
7 "date-fns": "4.4.0",
8 "@strapi/blocks-react-renderer": "1.0.2"
9 },
10 "overrides": {
11 "react-is": "^19.0.0"
12 }
13}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:
1// app/lib/session.ts
2import 'server-only';
3import { cookies } from 'next/headers';
4
5const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
6
7export async function login(identifier: string, password: string) {
8 const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
9 method: 'POST',
10 headers: { 'Content-Type': 'application/json' },
11 body: JSON.stringify({ identifier, password }),
12 });
13
14 if (!res.ok) {
15 throw new Error('Invalid credentials');
16 }
17
18 const data = await res.json();
19 const cookieStore = await cookies();
20 cookieStore.set('session', data.jwt, {
21 httpOnly: true,
22 secure: process.env.NODE_ENV === 'production',
23 sameSite: 'lax',
24 path: '/',
25 });
26
27 return data.user;
28}
29
30export async function getToken(): Promise<string | undefined> {
31 const cookieStore = await cookies();
32 return cookieStore.get('session')?.value;
33}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:
1// proxy.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4const publicRoutes = ['/login', '/signup'];
5
6export default function proxy(req: NextRequest) {
7 const path = req.nextUrl.pathname;
8 const isPublicRoute = publicRoutes.includes(path);
9
10 const session = req.cookies.get('session')?.value;
11
12 if (!isPublicRoute && !session) {
13 return NextResponse.redirect(new URL('/login', req.url));
14 }
15
16 if (isPublicRoute && session) {
17 return NextResponse.redirect(new URL('/dashboard', req.url));
18 }
19}
20
21export const config = {
22 matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
23};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:
1// app/lib/dal.ts
2import 'server-only';
3import { cache } from 'react';
4import { redirect } from 'next/navigation';
5import { getToken } from './session';
6
7const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
8
9export const verifySession = cache(async () => {
10 const token = await getToken();
11
12 if (!token) {
13 redirect('/login');
14 }
15
16 const res = await fetch(`${STRAPI_URL}/api/users/me`, {
17 headers: { Authorization: `Bearer ${token}` },
18 });
19
20 if (!res.ok) {
21 redirect('/login');
22 }
23
24 const user = await res.json();
25 return { token, userId: user.id };
26});React's cache memoizes the verification across a single render pass. Call verifySession() in Server Components, Server Actions, and Route Handlers.
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:
1'use server';
2// app/actions/mood.ts
3
4import { revalidatePath } from 'next/cache';
5import { verifySession } from '@/app/lib/dal';
6
7const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
8
9export async function createMoodEntry(formData: FormData) {
10 const { token } = await verifySession();
11
12 const mood = formData.get('mood')?.toString();
13 const note = formData.get('note')?.toString() ?? '';
14 const tags = formData.getAll('tags').map((t) => t.toString());
15
16 if (!mood) {
17 throw new Error('Mood is required');
18 }
19
20 const res = await fetch(`${STRAPI_URL}/api/mood-entries`, {
21 method: 'POST',
22 headers: {
23 'Content-Type': 'application/json',
24 Authorization: `Bearer ${token}`,
25 },
26 body: JSON.stringify({
27 data: {
28 mood,
29 notes: note,
30 tags,
31 loggedAt: new Date().toISOString(),
32 },
33 }),
34 });
35
36 if (!res.ok) {
37 throw new Error('Failed to save mood entry');
38 }
39
40 const { data } = await res.json();
41 revalidatePath('/dashboard');
42 return data.documentId;
43}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:
1'use client';
2// app/components/MoodLogger.tsx
3
4import { useState } from 'react';
5import { createMoodEntry } from '@/app/actions/mood';
6
7const MOODS = [
8 { value: '1', emoji: '😞', label: 'Awful' },
9 { value: '2', emoji: '🙁', label: 'Bad' },
10 { value: '3', emoji: '😐', label: 'Okay' },
11 { value: '4', emoji: '🙂', label: 'Good' },
12 { value: '5', emoji: '😄', label: 'Great' },
13];
14
15const TAGS = ['sleep', 'exercise', 'social', 'work', 'nutrition', 'weather'];
16
17export default function MoodLogger() {
18 const [selectedMood, setSelectedMood] = useState('');
19 const [selectedTags, setSelectedTags] = useState<string[]>([]);
20
21 function toggleTag(tag: string) {
22 setSelectedTags((prev) =>
23 prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
24 );
25 }
26
27 return (
28 <form action={createMoodEntry} className="space-y-4 rounded-lg border p-6">
29 <fieldset>
30 <legend className="mb-2 font-medium">How are you feeling?</legend>
31 <div className="flex gap-2">
32 {MOODS.map((m) => (
33 <label
34 key={m.value}
35 className={`cursor-pointer rounded-lg border px-4 py-3 text-center ${
36 selectedMood === m.value
37 ? 'border-indigo-500 bg-indigo-50'
38 : 'border-neutral-200'
39 }`}
40 >
41 <input
42 type="radio"
43 name="mood"
44 value={m.value}
45 className="sr-only"
46 onChange={() => setSelectedMood(m.value)}
47 />
48 <span className="block text-2xl">{m.emoji}</span>
49 <span className="text-xs">{m.label}</span>
50 </label>
51 ))}
52 </div>
53 </fieldset>
54
55 <fieldset>
56 <legend className="mb-2 font-medium">Context</legend>
57 <div className="flex flex-wrap gap-2">
58 {TAGS.map((tag) => (
59 <label
60 key={tag}
61 className={`cursor-pointer rounded-full border px-3 py-1 text-sm ${
62 selectedTags.includes(tag)
63 ? 'border-indigo-500 bg-indigo-50'
64 : 'border-neutral-200'
65 }`}
66 >
67 <input
68 type="checkbox"
69 name="tags"
70 value={tag}
71 className="sr-only"
72 checked={selectedTags.includes(tag)}
73 onChange={() => toggleTag(tag)}
74 />
75 {tag}
76 </label>
77 ))}
78 </div>
79 </fieldset>
80
81 <textarea
82 name="note"
83 rows={2}
84 maxLength={280}
85 placeholder="Optional note..."
86 className="w-full rounded-lg border p-2"
87 />
88
89 <button
90 type="submit"
91 disabled={!selectedMood}
92 className="rounded-lg bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
93 >
94 Log mood
95 </button>
96 </form>
97 );
98}The tags checkboxes share the name="tags", so formData.getAll('tags') returns every selected value as an array.
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:
1'use server';
2// app/actions/journal.ts
3
4import { revalidatePath } from 'next/cache';
5import { verifySession } from '@/app/lib/dal';
6
7const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
8
9type BlockNode = { type: string; children: { type: string; text: string }[] };
10
11export async function createJournalEntry(
12 title: string,
13 text: string,
14 moodEntryDocumentId?: string
15) {
16 const { token } = await verifySession();
17
18 const content: BlockNode[] = text
19 .split('\n\n')
20 .filter(Boolean)
21 .map((paragraph) => ({
22 type: 'paragraph',
23 children: [{ type: 'text', text: paragraph }],
24 }));
25
26 const res = await fetch(`${STRAPI_URL}/api/journal-entries`, {
27 method: 'POST',
28 headers: {
29 'Content-Type': 'application/json',
30 Authorization: `Bearer ${token}`,
31 },
32 body: JSON.stringify({
33 data: {
34 title,
35 content,
36 date: new Date().toISOString().slice(0, 10),
37 ...(moodEntryDocumentId
38 ? { moodEntry: { connect: [moodEntryDocumentId] } }
39 : {}),
40 },
41 }),
42 });
43
44 if (!res.ok) {
45 throw new Error('Failed to save journal entry');
46 }
47
48 revalidatePath('/journal');
49}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:
1'use client';
2// app/components/BlockRendererClient.tsx
3
4import {
5 BlocksRenderer,
6 type BlocksContent,
7} from '@strapi/blocks-react-renderer';
8
9export default function BlockRendererClient({
10 content,
11}: {
12 readonly content: BlocksContent;
13}) {
14 if (!content) return null;
15
16 return (
17 <BlocksRenderer
18 content={content}
19 blocks={{
20 paragraph: ({ children }) => (
21 <p className="leading-relaxed text-neutral-900">{children}</p>
22 ),
23 heading: ({ children, level }) => {
24 const tags = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4' } as const;
25 const Tag = tags[level] ?? 'h4';
26 return <Tag className="font-bold">{children}</Tag>;
27 },
28 list: ({ children, format }) =>
29 format === 'ordered' ? (
30 <ol className="ml-6 list-decimal">{children}</ol>
31 ) : (
32 <ul className="ml-6 list-disc">{children}</ul>
33 ),
34 }}
35 />
36 );
37}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:
1// app/journal/page.tsx
2import { type BlocksContent } from '@strapi/blocks-react-renderer';
3import { format } from 'date-fns';
4import { verifySession } from '@/app/lib/dal';
5import BlockRendererClient from '@/app/components/BlockRendererClient';
6
7const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
8
9type JournalEntry = {
10 documentId: string;
11 title: string;
12 date: string;
13 content: BlocksContent;
14};
15
16async function getJournalEntries(token: string): Promise<JournalEntry[]> {
17 const res = await fetch(
18 `${STRAPI_URL}/api/journal-entries?sort=date:desc`,
19 {
20 headers: { Authorization: `Bearer ${token}` },
21 cache: 'no-store',
22 }
23 );
24 const { data } = await res.json();
25 return data;
26}
27
28export default async function JournalPage() {
29 const { token } = await verifySession();
30 const entries = await getJournalEntries(token);
31
32 return (
33 <main className="mx-auto max-w-2xl space-y-8 p-6">
34 <h1 className="text-2xl font-bold">Journal</h1>
35 {entries.map((entry) => (
36 <article key={entry.documentId} className="rounded-lg border p-6">
37 <header className="mb-3">
38 <h2 className="text-lg font-semibold">{entry.title}</h2>
39 <time className="text-sm text-neutral-500">
40 {format(new Date(entry.date), 'PPP')}
41 </time>
42 </header>
43 <BlockRendererClient content={entry.content} />
44 </article>
45 ))}
46 </main>
47 );
48}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:
1'use client';
2// app/components/MoodCharts.tsx
3
4import {
5 LineChart,
6 Line,
7 BarChart,
8 Bar,
9 CartesianGrid,
10 XAxis,
11 YAxis,
12 Tooltip,
13 ResponsiveContainer,
14} from 'recharts';
15
16type TrendPoint = { date: string; averageMood: number };
17type TagPoint = { tag: string; averageMood: number };
18
19export function MoodLineChart({ data }: { data: TrendPoint[] }) {
20 return (
21 <ResponsiveContainer width="100%" height={280}>
22 <LineChart id="mood-line" data={data}>
23 <CartesianGrid stroke="#e0e0e0" strokeDasharray="5 5" />
24 <XAxis dataKey="date" />
25 <YAxis domain={[1, 5]} />
26 <Tooltip />
27 <Line
28 type="monotone"
29 dataKey="averageMood"
30 stroke="#6366f1"
31 dot={{ fill: '#fff' }}
32 />
33 </LineChart>
34 </ResponsiveContainer>
35 );
36}
37
38export function MoodByTagChart({ data }: { data: TagPoint[] }) {
39 return (
40 <ResponsiveContainer width="100%" height={280}>
41 <BarChart id="mood-tag" data={data}>
42 <CartesianGrid stroke="#e0e0e0" strokeDasharray="5 5" />
43 <XAxis dataKey="tag" />
44 <YAxis domain={[0, 5]} />
45 <Tooltip />
46 <Bar dataKey="averageMood" fill="#6366f1" />
47 </BarChart>
48 </ResponsiveContainer>
49 );
50}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:
1'use client';
2// app/components/StreakHeatmap.tsx
3
4import { eachDayOfInterval, subDays, format } from 'date-fns';
5
6export default function StreakHeatmap({ loggedDays }: { loggedDays: string[] }) {
7 const logged = new Set(loggedDays);
8 const days = eachDayOfInterval({
9 start: subDays(new Date(), 90),
10 end: new Date(),
11 });
12
13 return (
14 <div className="flex flex-wrap gap-1">
15 {days.map((day) => {
16 const key = format(day, 'yyyy-MM-dd');
17 return (
18 <span
19 key={key}
20 title={key}
21 className={`h-3 w-3 rounded-sm ${
22 logged.has(key) ? 'bg-indigo-500' : 'bg-neutral-200'
23 }`}
24 />
25 );
26 })}
27 </div>
28 );
29}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:
1// app/dashboard/page.tsx
2import { verifySession } from '@/app/lib/dal';
3import MoodLogger from '@/app/components/MoodLogger';
4import { MoodLineChart, MoodByTagChart } from '@/app/components/MoodCharts';
5import StreakHeatmap from '@/app/components/StreakHeatmap';
6
7const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
8
9async function getData(token: string) {
10 const headers = { Authorization: `Bearer ${token}` };
11
12 const [trendsRes, streakRes, daysRes] = await Promise.all([
13 fetch(`${STRAPI_URL}/api/mood-entries/trends?window=30`, {
14 headers,
15 cache: 'no-store',
16 }),
17 fetch(`${STRAPI_URL}/api/mood-entries/streak`, {
18 headers,
19 cache: 'no-store',
20 }),
21 fetch(`${STRAPI_URL}/api/mood-entries?fields[0]=loggedAt&pagination[pageSize]=365`, {
22 headers,
23 cache: 'no-store',
24 }),
25 ]);
26
27 const { data: trendData } = await trendsRes.json();
28 const { data: streak } = await streakRes.json();
29 const { data: entries } = await daysRes.json();
30
31 const loggedDays = entries.map((e: { loggedAt: string }) =>
32 e.loggedAt.slice(0, 10)
33 );
34
35 return { trends: trendData.trends, distribution: trendData.distribution, streak, loggedDays };
36}
37
38export default async function DashboardPage() {
39 const { token } = await verifySession();
40 const { trends, distribution, streak, loggedDays } = await getData(token);
41
42 return (
43 <main className="mx-auto max-w-3xl space-y-8 p-6">
44 <header className="flex items-center justify-between">
45 <h1 className="text-2xl font-bold">Dashboard</h1>
46 <div className="flex gap-4 text-sm">
47 <span>
48 Current streak: <strong>{streak.currentStreak}</strong>
49 </span>
50 <span>
51 Longest: <strong>{streak.longestStreak}</strong>
52 </span>
53 </div>
54 </header>
55
56 <MoodLogger />
57
58 <section>
59 <h2 className="mb-2 font-semibold">Mood over time</h2>
60 <MoodLineChart data={trends} />
61 </section>
62
63 <section>
64 <h2 className="mb-2 font-semibold">Average mood by tag</h2>
65 <MoodByTagChart data={distribution} />
66 </section>
67
68 <section>
69 <h2 className="mb-2 font-semibold">Logging consistency</h2>
70 <StreakHeatmap loggedDays={loggedDays} />
71 </section>
72 </main>
73 );
74}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.
Run both servers. Strapi on port 1337, Next.js on 3000. Walk through the full flow:
POST /api/auth/local/register, then log in at /login. The session cookie is set, and the proxy redirects you to /dashboard.createMoodEntry Server 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./journal and write an entry. Pass the mood entry's documentId so the createJournalEntry action links the two records through the connect relation syntax. The Blocks content saves as structured JSON.GET /api/mood-entries/{documentId} with the second user's token. The is-owner policy compares the document's user relation against policyContext.state.user.id and rejects the request if no matching owned content is found. List queries return only the second user's own entries because the controller filters every find by 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.
You have a working private mood tracker. A few directions to take it further:
GET /api/users/me/export that reads ctx.state.user, runs findMany() across both content-types filtered by user ID, and serializes the result to JSON. This is the GDPR data portability pattern.node-schedule. Register a job in bootstrap that checks for users who haven't logged today and queues a notification.The Strapi documentation and Next.js docs cover each of these in depth.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.