FullstackAgencySelection

Finding a Fullstack Agency: What Actually Matters

March 10, 202613 min read

The Problem with "Fullstack"

Every other agency calls themselves "fullstack" today. The problem: the term isn't protected and gets thrown around freely. A WordPress shop that makes a few REST calls through a plugin calls itself fullstack just like an engineering studio that runs its own deployment pipelines, infrastructure-as-code, and typed APIs.

The consequence: you're comparing apples to oranges. Prices vary by a factor of 5 because the services behind the same label are completely different.

As an engineering team, we've inherited code from agencies that was supposedly "professionally" developed. Some repositories were clean and well-thought-out. Others were a graveyard of any types. This article shows you how to tell the difference — before you sign the contract.

Code Doesn't Lie: Good vs. Bad Engineering

Before we talk about soft criteria, let's look at what makes the difference at the code level. Here's the same feature — an API endpoint for user registration — implemented poorly and professionally.

What Bad Code Looks Like

// POST /api/register — the "just make it work" version
app.post('/api/register', async (req, res) => {
  const { email, password, name } = req.body

  // No validation. None at all.
  // SQL Injection? Sure, come right in.
  const result = await db.query(
    `INSERT INTO users (email, password, name)
     VALUES ('${email}', '${password}', '${name}')
     RETURNING *`
  )

  // Password stored in plain text. No hashing.
  // And we send it right back in the response too.
  console.log('User registered:', result.rows[0])

  res.json({ success: true, user: result.rows[0] })
})

What's wrong here:

  • SQL Injection via string interpolation — an attacker can delete the entire database
  • Plain-text passwords — security breach and GDPR violation
  • No input validation — empty fields, invalid emails, everything passes through
  • console.log in production — personal data in server logs
  • No error handling — a database error crashes the entire process
  • No return type — nobody knows what the API returns

What Professional Code Looks Like

// schemas/auth.ts — Validation with Zod
import { z } from 'zod'

export const registerSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'At least one uppercase letter required')
    .regex(/[0-9]/, 'At least one number required'),
  name: z.string().min(2).max(100),
})

export type RegisterInput = z.infer<typeof registerSchema>
// routes/auth.ts — the same endpoint, done right
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { registerSchema } from '../schemas/auth'
import { db } from '../lib/database'
import { AppError } from '../lib/errors'
import { logger } from '../lib/logger'

const router = Router()

router.post('/register', async (req, res, next) => {
  try {
    // 1. Input validation
    const parsed = registerSchema.safeParse(req.body)
    if (!parsed.success) {
      throw new AppError(400, 'Invalid input data', parsed.error.flatten())
    }

    const { email, password, name } = parsed.data

    // 2. Check if email already exists
    const existing = await db.query(
      'SELECT id FROM users WHERE email = $1',
      [email]
    )
    if (existing.rows.length > 0) {
      throw new AppError(409, 'This email address is already registered')
    }

    // 3. Hash password — never store in plain text
    const passwordHash = await bcrypt.hash(password, 12)

    // 4. Parameterized query — SQL injection impossible
    const result = await db.query(
      `INSERT INTO users (email, password_hash, name)
       VALUES ($1, $2, $3)
       RETURNING id, email, name, created_at`,
      [email, passwordHash, name]
    )

    // 5. Structured logging — no sensitive data
    logger.info('User registered', { userId: result.rows[0].id })

    // 6. No password hash in the response
    res.status(201).json({
      success: true,
      user: result.rows[0],
    })
  } catch (error) {
    next(error) // centralized error handling
  }
})

The difference isn't "better" vs. "worse." The first code is a security incident waiting to happen. The second is standard engineering. If an agency delivers code that looks like example 1, you have a serious problem.

Who Is Actually Working on Your Project?

The most important question — and the one most frequently answered dishonestly. Many agencies sell with senior profiles in the pitch and then staff with juniors or offshore freelancers. You often don't notice until weeks later, when the code quality doesn't match the pitch.

Ask specifically:

  • Which people will work on my project? First and last names, LinkedIn profiles.
  • Do they have demonstrable experience with the specific tech stack?
  • Will the same people work continuously on the project?
  • Can I see a code review of an existing project?

A team switch mid-project costs you at least 2-3 weeks of onboarding — time you're paying for.

CI/CD: The Fastest Quality Indicator

Ask every agency about their deployment pipeline. If the answer is "we deploy manually via FTP" or a long silence, you have your answer.

A professional fullstack agency has an automated pipeline that runs tests on every push, validates the build, and automatically deploys on success. This isn't a nice-to-have — it's a baseline requirement.

Here's what a real CI/CD pipeline looks like:

# .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'

jobs:
  quality:
    name: Code Quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Type Check
        run: pnpm tsc --noEmit

      - name: Lint
        run: pnpm eslint . --max-warnings 0

      - name: Unit Tests
        run: pnpm vitest run --coverage

      - name: Upload Coverage
        uses: codecov/codecov-action@v4

  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    needs: quality
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }}
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm db:migrate
        env:
          DATABASE_URL: postgresql://test_user:${{ secrets.TEST_DB_PASSWORD }}@localhost:5432/test_db

      - name: Playwright Tests
        run: pnpm playwright test
        env:
          DATABASE_URL: postgresql://test_user:${{ secrets.TEST_DB_PASSWORD }}@localhost:5432/test_db

  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [quality, e2e]
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        run: vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

What you see here: type checking, linting, unit tests, E2E tests with a real database, and automatic deployment — only when everything passes. No manual intervention needed. No "let me just deploy from my laptop real quick."

Ask the agency: "Show me your pipeline." If there isn't one, that's a dealbreaker.

Development Setup: Docker Compose as Minimum Standard

Another quick test: what does the local development setup look like? A professional project needs exactly one command to start the entire development environment. Not three README pages of manual installation steps.

# docker-compose.yml — complete development environment
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - DATABASE_URL=postgresql://dev:dev@postgres:5432/app_dev
      - REDIS_URL=redis://redis:6379
      - NODE_ENV=development
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started

  postgres:
    image: postgres:16-alpine
    ports:
      - '5432:5432'
    environment:
      POSTGRES_DB: app_dev
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U dev']
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'

volumes:
  postgres_data:

One docker compose up — and everything is running. Database, cache, app. Every new developer is productive in 5 minutes, not 5 hours.

Red Flags in Code: What Bad Agencies Deliver

Over the past years we've inherited and audited code from various agencies. Here are the patterns that show up again and again — and that immediately reveal whether someone understands engineering or just delivers "it works."

Red Flag 1: any as the Default Type

// If you see this in a TypeScript project, it wasn't a TypeScript project.
// It was a JavaScript project with .ts file extensions.
const fetchData = async (url: any): Promise<any> => {
  const response: any = await fetch(url)
  const data: any = await response.json()
  return data
}

// Or even better: tsconfig with "strict: false"
// At that point you might as well skip TypeScript entirely.

TypeScript with any everywhere is worse than JavaScript — it gives you a false sense of security. You think you have type safety, but in reality you have nothing.

Red Flag 2: Secrets in the Code

// This should NEVER end up in the repository.
// And no, putting .env in .gitignore isn't enough
// when the values are directly in the code.
const stripe = new Stripe('sk_live_51ABC123...')
const dbUrl = 'postgresql://admin:SuperSecret123@prod-db.example.com:5432/production'

// Correct: Environment Variables
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const dbUrl = process.env.DATABASE_URL!

We've inherited projects where production database passwords were sitting in the Git history. Once a secret has been committed, it's compromised — even if you delete it afterwards. Git history never forgets.

Red Flag 3: No Tests, Just console.log

// "Debugging" in production — a classic
export async function processPayment(order: any) {
  console.log('order:', order)
  console.log('processing...')
  const result = await stripe.charges.create({
    amount: order.total, // Is this in cents? Euros? Who knows.
    currency: 'eur',
    source: order.token,
  })
  console.log('result:', result)
  console.log('DONE!!!!!')
  return result
}

When console.log is the only debugging tool and there are zero tests, this code was never seriously reviewed. In a professional codebase you'll find structured logging (Winston, Pino), error tracking (Sentry), and tests that run before every deployment.

Evaluation Scorecard: Rating Agencies Systematically

Use this scorecard during your discovery calls. Rate each agency on a scale of 1-5 and compare results objectively.

| Criterion | 1 (Poor) | 3 (Acceptable) | 5 (Excellent) | |---|---|---|---| | Team Continuity | "We're still looking for the right people" | Fixed team, but changes possible | Named, fixed team with track record | | Tech Stack Depth | 47 logos on the website, a bit of everything | Clear stack, but limited architecture expertise | Clear stack with justified architecture decisions | | CI/CD Maturity | Manual deployment, no staging | Automated build, but tests missing | Full pipeline: Lint, Types, Tests, Preview, Production | | Code Ownership | "You get the code after project ends" | Shared repository, access on request | Your repository from day 1, you are the owner | | Communication | Monthly status updates via email | Weekly calls with summary | Async updates via Slack/GitHub, daily commit access | | NDA / IP Protection | NDA only on request, unclear IP terms | Standard NDA available | NDA before the first meeting, IP belongs to you, contractually fixed | | Delivery Track Record | No references or only vague descriptions | 1-2 comparable projects shown | Multiple comparable projects with measurable results |

Minimum for a serious engagement: Average of 3 or higher, no single score below 2. If CI/CD or code ownership is below 3, that's a serious warning sign.

Tech Stack Interview: 7 Questions You Should Ask

These questions separate agencies that understand engineering from those that just implement.

1. "How do you ensure type safety across the entire stack?"

  • Bad answer: "We use TypeScript." (Period. No details.)
  • Good answer: "We use TypeScript strict mode, generate API types from the schema (e.g. with Zod or tRPC), and our database queries are also typed — for example with Drizzle or Prisma. End-to-end type safety from frontend to database."

2. "How do you handle database migrations in production?"

  • Bad answer: "We adjust the schema manually." or "We export and import."
  • Good answer: "Versioned migration files that run automatically during deployment. Every migration is reversible. We test migrations against a staging database first before running them in production."

3. "What happens when your deployment fails at 3 AM?"

  • Bad answer: "That's never happened to us."
  • Good answer: "Automatic rollback to the last working version. Alerting via PagerDuty/Opsgenie. Health checks that detect failed deployments before traffic is routed."

4. "How do you handle environment-specific configuration?"

  • Bad answer: "We have a config.js with if-else for development and production."
  • Good answer: "Environment variables, validated at app startup with a schema. Secrets are stored in a secret manager (e.g. Vault, AWS Secrets Manager) or as encrypted GitHub Secrets. None of it is in the repository."

5. "Show me your testing approach for a typical project."

  • Bad answer: "We test manually." or "We write tests if there's time left at the end."
  • Good answer: "Unit tests for business logic, integration tests for API endpoints with a real test database, E2E tests for critical user flows. Tests run automatically in the CI pipeline and block deployment on failure."

6. "How do you structure a monorepo with frontend and backend?"

  • Bad answer: "Frontend and backend are in separate repos without shared types."
  • Good answer: "Monorepo with Turborepo or Nx. Shared packages for types, validation, and utilities. Dependencies are clearly defined, builds run in parallel and are cached."

7. "What's your strategy for error handling and observability?"

  • Bad answer: "We log errors to the console and check periodically."
  • Good answer: "Sentry for error tracking with source maps, structured logging with correlation IDs, uptime monitoring. We're automatically notified of critical errors, not when the customer reports it."

If an agency can't give a convincing answer to more than two of these questions, they lack the technical depth you need for a serious product.

Confidentiality and IP: Non-Negotiable

Especially for startups with innovative ideas, IP is critical:

  • NDA before the first conversation — not only on request. Every serious agency has a standard NDA ready.
  • Clear IP agreement — all code belongs to you. No clauses granting the agency rights to "reusable components."
  • No showcasing your project without your explicit, written permission.
  • Repository access from day 1. You are the owner, not a guest.

The Right Timing

You need a fullstack agency when you want to build a technical product but don't have your own engineering team — or when your team needs reinforcement that delivers at senior level from day one.

Take the time for 2-3 discovery calls. Use the scorecard. Ask the interview questions. And ask to see code — not slide decks.

How We Work at SecretStack

We're an AI-native fullstack engineering studio. Our stack: Next.js, TypeScript, Tailwind, Node.js/Python, PostgreSQL. Not because it's trendy, but because this stack works optimally for 90% of SaaS and AI products.

What that means in practice:

  • Fixed team — the people in the pitch are the people working on your project
  • Your repository, your code — from day 1
  • CI/CD from sprint 1 — not as an afterthought in week 8
  • Async communication — daily commits, weekly demos, Slack for everything in between
  • NDA by default — before we even discuss your project

Sounds like what you're looking for? Book a discovery call — let's explore together if and how we can build your project.

SS
SecretStack

AI-native fullstack engineering studio. We build what others can't see — with senior engineers, NDA from first contact.

NDA-first
Senior-only
120+ Projects

Senior fullstack engineers for your project?

Want to discuss your project with senior engineers?

Get a free consultation