ANDREW THE CODER

2026-01-01 ~ 8 min read

Building WeighTogether Without Heavy Frameworks


Image for Building WeighTogether Without Heavy Frameworks

WeighTogether is a full-stack weight loss tracking application with social features. What makes it interesting from a technical perspective isn’t what it uses—it’s what it doesn’t. The entire application is built without Next.js, Nuxt, Rails, or any other heavy framework. Instead, it relies on fundamentals: TypeScript, Express.js, PostgreSQL, and a careful layering of concerns.

This post explores the technologies and design decisions that went into building WeighTogether.

The Stack

The technology choices prioritize simplicity and control:

LayerTechnologyPurpose
RuntimeNode.js 20JavaScript execution
LanguageTypeScript 5.3Type safety
FrameworkExpress.js 4.18Minimal HTTP handling
DatabasePostgreSQL 16Primary data store
ORMPrisma 7Type-safe database access
Real-timeSocket.io 4.8Messaging and notifications
TemplatesEJSServer-side rendering
StylingTailwind CSS 4Utility-first CSS
InteractivityAlpine.jsLightweight reactivity

Architecture: Layered Without a Framework

The application follows a classic MVC pattern with a service layer:

Request → Logger → LoadUser → Auth → Controller → Service → Prisma → Response

The directory structure reflects this separation:

src/
├── config/        # Environment and auth configuration
├── controllers/   # Request handlers
├── middleware/    # Auth, logging, file upload
├── routes/        # Route definitions
├── services/      # Business logic
├── types/         # TypeScript definitions
├── views/         # EJS templates
│   └── partials/  # Reusable components
└── server.ts      # Entry point

Each layer has a single responsibility. Controllers handle HTTP concerns and validation. Services contain business logic. Prisma handles database access. This isn’t revolutionary—it’s just good architecture, implemented without framework magic obscuring the flow.

Static Methods Over Instances

Controllers and services use static methods rather than class instances:

// src/controllers/WeightController.ts

class WeightController {
  static entryValidation = [
    body("weight").notEmpty().isFloat({ min: 0.1, max: 1000 }),
    body("recordedAt").notEmpty().isISO8601(),
  ];

  static async index(req: AuthenticatedRequest, res: Response) {
    const entries = await WeightService.getEntriesForUser(req.user.id);
    res.render("weight/index", { entries });
  }

  static async logSubmit(req: AuthenticatedRequest, res: Response) {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.render("weight/log", { errors: errors.array() });
    }

    await WeightService.createEntry(req.user.id, req.body);
    res.redirect("/progress?success=Weight+logged");
  }
}

Services follow the same pattern:

// src/services/AuthService.ts

class AuthService {
  static async hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, authConfig.bcrypt.saltRounds);
  }

  static async verifyPassword(
    password: string,
    hash: string,
  ): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }

  static generateTokenPair(payload: JwtPayload): TokenPair {
    const accessToken = jwt.sign(payload, authConfig.jwt.accessTokenSecret, {
      expiresIn: "15m",
    });
    const refreshToken = jwt.sign(payload, authConfig.jwt.refreshTokenSecret, {
      expiresIn: "7d",
    });
    return { accessToken, refreshToken };
  }
}

This approach eliminates instantiation overhead and makes the code easier to test. There’s no hidden state—each method is a pure function of its inputs.

Dual Authentication Strategy

The application serves both a web interface and an API, each with different authentication needs.

API routes use JWT Bearer tokens:

// src/middleware/auth.ts

export const authenticate = (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ success: false, message: "Token required" });
  }

  const token = authHeader.split(" ")[1];
  req.user = AuthService.verifyAccessToken(token);
  next();
};

Web routes use HTTP-only cookies with automatic token refresh:

// src/middleware/webAuth.ts

export const webAuthenticate = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  const accessToken = req.cookies.accessToken;
  if (accessToken) {
    try {
      req.user = AuthService.verifyAccessToken(accessToken);
      return next();
    } catch {
      // Token expired, try refresh
    }
  }

  const refreshToken = req.cookies[authConfig.cookie.refreshTokenName];
  if (!refreshToken) {
    return res.redirect("/login");
  }

  // Refresh both tokens
  const tokens = await AuthService.refreshTokens(refreshToken);
  res.cookie("accessToken", tokens.accessToken, {
    httpOnly: true,
    maxAge: 900000,
  });
  res.cookie(
    authConfig.cookie.refreshTokenName,
    tokens.refreshToken,
    cookieOptions,
  );

  req.user = AuthService.verifyAccessToken(tokens.accessToken);
  next();
};

The web middleware silently refreshes expired access tokens using the refresh token stored in an HTTP-only cookie. Users stay logged in without noticing the token rotation happening behind the scenes.

Prisma 7 with the Driver Adapter

WeighTogether uses Prisma 7’s new driver adapter pattern:

// src/services/database.ts

import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });

export default prisma;

The schema uses UUIDs for primary keys and includes careful indexing:

model WeightEntry {
    id                  String           @id @default(uuid())
    userId              String
    user                User             @relation(fields: [userId], references: [id])

    weight              Float
    bodyFatPercentage   Float?
    muscleMass          Float?
    notes               String?
    recordedAt          DateTime         @default(now())
    visibility          EntryVisibility  @default(PRIVATE)

    photos              ProgressPhoto[]

    @@index([userId, recordedAt(sort: Desc)])
}

The composite index on [userId, recordedAt] optimizes the most common query pattern: fetching a user’s entries in reverse chronological order.

Server-Rendered with Progressive Enhancement

The frontend uses EJS templates with Alpine.js for interactivity. The master layout is remarkably concise:

<!-- src/views/layout.ejs -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <%- include('partials/_head') %>
  </head>
  <body class="min-h-screen bg-gray-100">
    <%- include('partials/_nav') %>
    <div class="max-w-6xl mx-auto px-4 py-6"><%- body %></div>
    <%- include('partials/_toast') %> <%- include('partials/_scripts') %>
  </body>
</html>

Alpine.js handles client-side interactions without the overhead of a full SPA framework:

<!-- Dropdown menu -->
<div class="relative" x-data="{ open: false }">
  <button @click="open = !open" @click.away="open = false">
    <img src="/avatars/user.jpg" class="w-8 h-8 rounded-full" />
  </button>
  <div
    x-show="open"
    x-transition
    class="absolute right-0 mt-2 w-48 bg-white shadow-lg"
  >
    <a href="/profile">Profile</a>
    <a href="/settings">Settings</a>
    <form action="/logout" method="POST">
      <button type="submit">Logout</button>
    </form>
  </div>
</div>

A global toast system uses Alpine’s store feature:

Alpine.store("toasts", {
  items: [],
  add(message, type = "info") {
    const id = Date.now();
    this.items.push({ id, message, type });
    setTimeout(() => this.remove(id), 5000);
  },
  remove(id) {
    this.items = this.items.filter((t) => t.id !== id);
  },
});

window.toast = {
  success: (msg) => Alpine.store("toasts").add(msg, "success"),
  error: (msg) => Alpine.store("toasts").add(msg, "error"),
};

Real-Time Messaging with Socket.io

Socket.io powers the messaging system and live notifications:

// Client-side
const socket = io({
  auth: { userId: currentUserId },
  reconnectionAttempts: 3,
});

socket.on("newMessage", (data) => {
  if (!window.location.pathname.startsWith("/messages")) {
    toast.info(`${data.senderName}: ${data.content}`);
  }
  window.dispatchEvent(new CustomEvent("wlt:newMessage", { detail: data }));
});

The server authenticates socket connections using the same JWT infrastructure as the REST API, ensuring consistent security across both communication channels.

Achievement System

An achievement system rewards users for milestones and consistency:

// src/services/AchievementService.ts

class AchievementService {
  static async checkWeightAchievements(userId: string): Promise<Achievement[]> {
    const unlocked: Achievement[] = [];
    const entries = await prisma.weightEntry.findMany({
      where: { userId },
      orderBy: { recordedAt: "asc" },
    });

    // First Weigh-In
    if (entries.length === 1) {
      const achievement = await this.award(userId, "first-weigh-in");
      if (achievement) unlocked.push(achievement);
    }

    // Weight loss milestones
    const startWeight = entries[0].weight;
    const currentWeight = entries[entries.length - 1].weight;
    const lost = startWeight - currentWeight;

    for (const milestone of [5, 10, 25, 50, 100]) {
      if (lost >= milestone) {
        const achievement = await this.award(userId, `lost-${milestone}-lbs`);
        if (achievement) unlocked.push(achievement);
      }
    }

    return unlocked;
  }
}

Hidden achievements add an element of discovery:

static async checkHiddenAchievements(userId: string, weight: number, recordedAt: Date) {
    const hour = recordedAt.getHours();

    // Night Owl - logged between midnight and 4 AM
    if (hour >= 0 && hour < 4) {
        await this.award(userId, 'night-owl');
    }

    // Precision Master - weight is a whole number
    if (weight % 1 === 0) {
        await this.award(userId, 'precision-master');
    }
}

Testing Strategy

Jest handles both unit and integration tests. Test factories create authenticated users with valid tokens:

// tests/helpers/factories.ts

export async function createAuthenticatedUser(overrides = {}) {
  const passwordHash = await AuthService.hashPassword("password123");
  const user = await prisma.user.create({
    data: {
      email: `test-${Date.now()}@example.com`,
      username: `user${Date.now()}`,
      passwordHash,
      emailVerified: true,
      ...overrides,
    },
  });

  const payload = await AuthService.buildJwtPayload(user.id);
  const tokens = AuthService.generateTokenPair(payload);

  await prisma.refreshToken.create({
    data: {
      token: tokens.refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  return { user, tokens };
}

Integration tests run against a real PostgreSQL instance:

describe("POST /api/auth/login", () => {
  it("returns tokens for valid credentials", async () => {
    await createAuthenticatedUser({ email: "test@example.com" });

    const response = await request(app)
      .post("/api/auth/login")
      .send({ email: "test@example.com", password: "password123" })
      .expect(200);

    expect(response.body.success).toBe(true);
    expect(response.body.data.tokens.accessToken).toBeDefined();
  });
});

Why No Framework?

The decision to avoid heavy frameworks was deliberate. Frameworks like Next.js or Rails are excellent tools, but they come with tradeoffs:

  • Opacity: Framework magic obscures the request lifecycle
  • Coupling: Your code becomes entangled with framework conventions
  • Churn: Framework updates can require significant rewrites
  • Overhead: Features you don’t need still affect bundle size and complexity

WeighTogether’s approach trades convenience for clarity. Every line of code is intentional and understandable. The request flow is explicit. Dependencies are minimal and stable.

This doesn’t mean frameworks are bad—they’re often the right choice. But for a project where I wanted complete control and a deep understanding of every component, building from fundamentals made sense.

Conclusion

WeighTogether demonstrates that modern, feature-rich web applications don’t require heavy frameworks. Express.js, Prisma, and PostgreSQL provide a solid foundation. EJS and Alpine.js deliver a responsive interface without SPA complexity. Socket.io enables real-time features.

The key is thoughtful architecture: clear separation of concerns, consistent patterns, and explicit data flow. These principles work regardless of the tools you choose.

The full source code is available on GitHub if you’d like to explore the implementation in detail.

Comments