technical deepdive
May 14, 2026Analysing Togetherish: An Async-First Team Communications Dashboard Built with Next.js, Supabase, and TypeScript.
Togetherish is an async-first communications dashboard for small remote teams (2-10 people).
Its core philosophy is alignment, not engagement. It is deliberately
positioned as a lightweight alternative to Slack, Notion, Teams, and Basecamp for creative studios,
freelance collectives, and micro remote teams.
Rather than replicating Slack-style real-time chat or Notion-style project management, Togetherish focuses on
four async primitives:
daily check-ins, team announcements, important links, and team pulse (omitted for MVP). This constraint keeps
the feature surface small, which makes the codebase readable and worth studying.
Here is a technical deep-dive into how it works under the hood.
What Togetherish Does
Togetherish lets small teams stay aligned with four core features:
- Daily check-ins: each member answers "what did you do yesterday?", "what are you doing today?", and "any blockers?", plus a 1-5 sentiment rating. One check-in per person per day, editable only on the same day.
- Announcements: admin-published broadcasts visible to the whole team. A simple title-and-body format with no threading or reactions.
- Important links: a curated, admin-managed set of quick-access URLs for the team.
- Team pulse: aggregate sentiment scores and check-in participation rates displayed on the dashboard
The interface is a single dashboard view: announcements full-width at the top, check-in feed on the left, important links on the right, and an admin panel tucked inside a dialog.
Tech Stack
| layer | technology |
|---|---|
| Framework | Next.js 15.5 with React 19.1 |
| Language | TypeScript 5.9 |
| Database | Supabase (PostgreSQL) with Row-Level Security |
| Auth | Supabase Auth (email/password + magic link) |
| Styling | Tailwind CSS 4.1 + Shadcn/UI (Radix primitives) |
| Forms | react-hook-form + Zod 4 |
| Resend + React Email | |
| Animations | Framer Motion 12 |
| Deployment | Vercel |
Next.js 15 provides file-system routing via the App Router, server-side API route handlers under app/api/, and React Server Components for data fetching, all in a single framework. Turbopack handles dev builds. TypeScript 5.9 runs in strict mode across the entire codebase.
Architecture at a Glance
Every layer has a distinct responsibility. Server components fetch and pass data down. Client components handle interactivity. API routes are thin authentication and authorisation layers that delegate to service classes. And the database enforces invariants at the storage level through RLS and triggers.
The Service Layer Pattern
The defining architectural decision in Togetherish is the service layer. Business logic lives exclusively in
service classes under /lib/services/.
There are seven service classes:
- AnnouncementService: CRUD for announcements
- CheckinService: daily check-ins with same-day edit enforcement and sentiment tracking
- ImportantLinkService: quick-access link management
- InvitationService: invitations with auto-assignment and capacity checks
- TeamService: team CRUD, membership verification, capacity management
- UserService: user profile CRUD with soft delete
- DashboardService: optimised dashboard data in exactly 2 queries
A typical flow looks like this:
// API route — thin, delegates immediately
export async function POST(req: Request, { params }: Props) {
return withErrorHandling(async () => {
const user = await authenticateUser(req);
const body = await req.json();
const validated = checkinSchema.parse(body);
const checkin = await CheckinService.create(user.id, params.teamId, validated);
return successResponse(checkin, "Check-in created");
});
}
// Service — all business logic lives here
export class CheckinService {
static async create(userId: string, teamId: string, data: CreateCheckin) {
await TeamService.verifyMembership(userId, teamId);
const existing = await getTodayCheckin(userId, teamId);
if (existing) throw new AppError("Already checked in today", 409);
return await db.from("checkins").insert({
team_id: teamId, user_id: userId,
yesterday: data.yesterday, today: data.today,
blockers: data.blockers, sentiment: data.sentiment
}).select().single();
}
}
This separation means API routes are testable in isolation, services are testable without HTTP, and changing the transport layer (say, moving from REST to GraphQL or tRPC) requires zero business logic changes.
Dashboard Optimisation: 2 Queries
The dashboard is the core of the application, yet DashboardService
reduces database round-trips to exactly two:
- Query 1: fetch the current user and their teams in parallel
- Query 2: fetch all team data (members, check-ins, announcements, links, invitations) in parallel using Supabase batched queries
This is achieved by structuring the second query as a set of independent Promise.all calls, all scoped to the same teamId. The result is assembled into a typed DashboardPayload and returned to the server component, which passes it as
initialData to the client.
Additionally, the frontend caches dashboard data in localStorage with a
5-minute TTL. Cache keys follow the pattern togetherish_{feature}_{teamId}. This means navigating between teams or
returning to the dashboard within five minutes can skip the network entirely.
Soft Delete by Default
Every user-data table (users, teams, team_members, checkins, announcements, important_links, invitations) uses
soft deletion. A deleted_at
nullable timestamp column is present on every entity:
usersadditionally uses anis_activeboolean for a double-delete pattern- Every query must filter
.is("deleted_at", null) - Hard deletes are never used for user-facing data
Soft deletion is a well-known pattern and applied here with consistency: all seven user-data tables use a deleted_at column, and every query filters on it. Soft delete gives you recoverability, referential integrity without cascading deletes, and the ability to audit deletions. The trade-off is that every query must remember the filter. The service layer pattern makes this manageable by centralising query construction.
The Permission Model
Three roles, enforced at two layers:
| role | permissions |
|---|---|
| Owner | Full control with the team creator. Can delete the team, manage all members, promote/demote admins. |
| Admin | Can manage content (create announcements, manage links, invite members) but cannot delete the team or change ownership. |
| Member | Can view dashboard, create and edit own check-ins, read announcements and links. |
Enforcement happens at two levels. In the application layer, service classes
call TeamService.verifyMembership() and TeamService.verifyAdminPermissions() before any write operation. At the
database level, PostgreSQL Row-Level Security ensures that unauthorised queries return empty results even if the
application layer is bypassed.
-- Database-level enforcement
CREATE OR REPLACE FUNCTION is_team_member(team_id UUID)
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM team_members
WHERE team_id = $1
AND user_id = auth.uid()
AND deleted_at IS NULL
);
$$ LANGUAGE sql SECURITY DEFINER;
The dual-layer approach is defence-in-depth: the service layer handles ergonomic error messages and structured responses, while RLS acts as a safety net against misconfiguration or bugs.
Authentication & Invitation Flow
Auth is handled by Supabase Auth with email/password and magic link flows. The invitation system is where it gets interesting:
Team capacity is checked at three points: the application service (before
creating the invitation), the application service (before accepting), and a database trigger (enforce_team_capacity) that fires on BEFORE INSERT on both team_members and invitations.
This guarantees the limit is never exceeded regardless of race conditions or code paths.
Middleware & Route Protection
The Next.js middleware (middleware.ts) checks authentication on
every request:
// Middleware logic
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
const isPublicPath = ['/', '/pricing', '/auth/signin', '/auth/signup',
'/auth/forgot-password', '/contact', '/privacy', '/terms',
'/instructions'].includes(pathname);
if (!isPublicPath) {
return Response.redirect(new URL('/auth/signin', request.url));
}
}
if (user && isAuthPage(pathname)) {
return Response.redirect(new URL('/dashboard', request.url));
}
The middleware is also where CSP headers are set, restricting script sources, style sources, Supabase connect sources, and form actions to prevent XSS.
Check-in Business Rules
The check-in feature has four rules that keep the data model simple:
- One per day: enforced by querying for an existing check-in by userId + teamId + today's date before insert. Returns HTTP 409 if one exists.
- Edit-once: a check-in can only be edited on
the same day it was created. Enforced by checking
updated_at === created_at. - Soft delete: deleting a check-in sets
deleted_atrather than removing the row. - Sentiment scale: 1-5 integer, rendered as emoji faces in the UI.
These constraints are enforced in CheckinService and checked again
by the database schema. No client-side logic is trusted.
Standardised Error Handling
Every API route wraps its handler in withErrorHandling(), which
provides consistent JSON error responses and catches unhandled exceptions. Four helper functions standardise
the response format:
successResponse(data, message?, status?)errorResponse(error, status?)validationErrorResponse(zodError)handleApiError(error)-> maps known error types to HTTP status codes
Zod schemas validate all inputs at the API route boundary. Invalid payloads return a structured error with field-level messages before any business logic executes. This pattern means the entire API surface has predictable, typed error handling with almost no boilerplate per route.
Component Tree
The component tree follows a strict server-client boundary:
Thirteen UI primitives (button, input, dialog, select, badge, avatar, card, tabs, textarea, label, alert,
skeleton, sonner) come from Shadcn/UI, which wraps Radix UI primitives. These are never modified directly.
The cn() utility from clsx
+ tailwind-merge handles
class composition throughout.
The CheckinFeed.tsx component at 825 lines is the largest
component. Followed by DashboardClient.tsx at 191 lines. DashboardClient.tsx receives typed initialData
from the server
component and manages the whole dashboard state: team toggling, check-in CRUD, announcement management, link
management, and the admin panel. Team switching works via URL parameter (router.push('/dashboard?teamId=X')), which triggers a server re-render.
Mobile & Navigation
Navigation comprises three components:
- Header.tsx: server component with sticky positioning, brand logo, nav links (Dashboard, How to Use, Profile), and auth state (Login vs. Logout buttons). Mobile-responsive with a hamburger toggle.
- MobileMenu.tsx: a fullscreen overlay menu
rendered via
createPortal. It dispatches a custom DOM event (togetherish:open-profile) to open the profile dialog from outside the React tree. - Footer.tsx: dark footer with brand and links. Minimal.
Key Architectural Decisions, Evaluated
| decision | why it works | trade-off |
|---|---|---|
| Service layer | API routes stay thin; transport-agnostic | More files, more indirection for simple operations |
| Soft delete | Recoverable deletes; no cascading issues; audit trail | Every query must filter deleted_at IS NULL; tables grow
without compaction |
| Dual-layer permissions | Defence-in-depth; RLS protects against app-layer bugs | Permission logic is duplicated (app + database); can drift |
| localStorage cache (5m TTL) | Fast team switching; reduces server load for repeated views | Stale data window; cache invalidation complexity |
| 3-point capacity enforcement | Race-condition-proof; no possible path to exceed 10 members | Triplicated logic (service + service + trigger) |
| Server components for data | Zero client-side data fetching boilerplate; automatic streaming | Harder to do real-time updates; requires router.refresh()
after mutations |
What I'd Build Next
A few directions worth exploring:
- Real-time updates: Supabase has Realtime
subscriptions. The dashboard could push new check-ins and announcements without requiring
router.refresh(). - Webhook notifications: send Slack/Discord webhooks when a new check-in or announcement is posted.
- Check-in prompts: automated email or in-app reminders at configurable times.
- Analytics: per-user and per-team check-in streaks, sentiment trends over time, participation rates.
- Paid plans: the database schema already has
plan(int) andmax_membersfields. Pricing infrastructure is ready; just needs Stripe.
Conclusion
Togetherish is a focused MVP codebase that demonstrates five production-grade patterns: a clean service layer, defence-in-depth security, optimised dashboard loading, soft-delete discipline, and standardised error handling.
The codebase is well-organised for its size: 23 API route files across 6 resource groups (auth, profile, teams, dashboard, send, users), 7 service classes, 42 custom components across 6 feature directories (auth, dashboard, landingpage, navigation, onboarding, profile), and 13 shadcn/ui primitives.
The result is a codebase whose data flow can be traced from middleware through API route to service to database, and back without crossing architectural boundaries in unexpected ways.
Try Togetherish, it's live at togetherish.io.