technical deepdive

Analysing 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:

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
Email 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

User hits /dashboard ↓ Server Component → DashboardService.getDashboardForUser(userId, teamId?) → Returns full DashboardPayload ↓ DashboardClient (use client) → Receives initialData as props → Renders check-in feed, announcements, links, team toggle → All mutations call API routes, then router.refresh() ↓ API Routes → Authenticate → Verify membership → Delegate to Service → Service → Supabase query (RLS enforced) → Return response ↓ Database (PostgreSQL + RLS) → Tables: users, teams, team_members, checkins, announcements, important_links, invitations → Soft delete everywhere (deleted_at) → Trigger-based team capacity enforcement

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:

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:

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:

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:

Admin invites member ↓ POST /api/send/[teamId] → Creates invitation record in DB (UUID token, status: pending) → Sends email via Resend with magic link ↓ Recipient clicks: /auth/signup?token={invitationToken} → Signs up → Auth callback exchanges code for session → Callback detects invitationToken param → InvitationService.acceptInvitation() runs: → Validates token → Checks team capacity (<= 10 members) → Adds user as "member" → Marks invitation as "accepted" → Redirect to /dashboard

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:

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:

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:

Server Components (default) ├── /dashboard/page.tsx - fetches data, passes to client ├── Header.tsx - auth-aware nav (reads session server-side) └── Footer.tsx - static content Client Components ("use client") ├── DashboardClient.tsx - orchestrates the entire dashboard ├── CheckinFeed.tsx - form, list, search, edit (825 lines) ├── Announcements.tsx - create/list/delete for admins ├── ImportantLinks.tsx - create/list/delete for admins ├── AdminClient.tsx - dialog-based team management ├── SigninForm.tsx - react-hook-form + Zod └── SignupForm.tsx - registration with invitation token

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:

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:

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.