As a soccer coach, I’ve tested a ton of tools to manage my team, but none of them really clicked. Maybe I’m extra picky — but between working full time as a developer and holding a UEFA B coaching license, I know exactly what I want. So instead of settling, I decided to build my own web app.
In this series, I’ll share the tech stack behind it and how I’m turning a coach’s frustrations into a product built from scratch.
My goal:
Maximum effectiveness and as little boilerplate code as possible — because as a father, coach, and developer, every minute counts.
To understand the context better, here’s a quick overview of the features of ManageYourTeam:
- Team management (user data, skills, absences, injuries)
- Event management (matches, trainings, team events)
- Availability tracking (who can make it to the next match/training)
- Match reports (stats, scores, notes)
- Trainings plans (drills, notes, attendance)
- Calendar (events, availability, sync with external calendars)
In my full time job, we use NestJS with Prisma in the Backend and Vue.js on the Frontend.
I want to show you a comparison of how I would implement a simple feature — adding and listing matches — with my enterprise tech stack versus using Remix.
To keep it simple, I’ll skip authentication, production ready error handling and other non-essential parts.
Using NestJS and Vue
A feature for listing all matches and adding a new match, would look like this:
// match.service.ts
@Injectable()
export class MatchService {
constructor(private readonly prisma: PrismaService) {}
getAllMatches() {
return this.prisma.match.findMany();
}
createMatch(opponent: string, date: Date) {
return this.prisma.match.create({
data: {
opponent,
date,
},
});
}
}
// match.dto.ts
export class CreateMatchDto {
@ApiProperty({ example: 'Manchester City' })
@IsString()
@MinLength(1, { message: 'Opponent must be at least 1 character long' })
opponent!: string;
@ApiProperty({ example: '2025-10-10T07:53:18' })
@Type(() => Date)
@IsDate({ message: 'Date must be a valid date' })
date!: Date;
}
export class MatchDto {
@ApiProperty({ example: '00000000-0000-0000-0000-000000000000' })
id: string;
@ApiProperty({ example: 'Manchester City' })
opponent: string;
@ApiProperty({ example: '2025-10-10T07:53:18' })
date: string;
constructor({
id,
opponent,
date,
}: {
id: string;
opponent: string;
date: Date;
}) {
this.id = id;
this.opponent = opponent;
this.date = date.toISOString();
}
}
// match.controller.ts
@Controller('matches')
export class MatchController {
constructor(private readonly matchService: MatchService) {}
@Get()
@ApiOkResponse({ type: [MatchDto] })
async getAllMatches() {
const matches = await this.matchService.getAllMatches();
return matches.map((match) => new MatchDto(match));
}
@Post()
@ApiOkResponse({ type: MatchDto })
async createMatch(@Body() dto: CreateMatchDto) {
const newMatch = await this.matchService.createMatch(
dto.opponent,
dto.date
);
return new MatchDto(newMatch);
}
}
On the frontend, I use:
- TypeScript programming language
- Vue.js framework
- Axios for fetching data from the backend
- TanStack Query to handle data fetching and mutations
<script setup lang="ts">
type MatchDto = {
id: string;
opponent: string;
date: string;
};
const queryKey = ['matches'] as const;
const {
data: matches,
isLoading,
error,
} = useQuery({
queryKey,
queryFn: async ({ signal }) => {
const { data } = await axios.get<MatchDto[]>('matches', {
signal,
});
return data;
},
});
const newMatchOpponent = ref('');
const newMatchDate = ref('');
const queryClient = useQueryClient();
const { mutate: createMatch, error: mutationError } = useMutation({
mutationFn: () =>
axios.post('matches', {
opponent: newMatchOpponent.value,
date: newMatchDate.value,
}),
onSuccess: async () => {
newMatchOpponent.value = '';
newMatchDate.value = '';
await queryClient.invalidateQueries({
queryKey,
});
},
});
</script>
<template>
<div>
<div>
<form @submit="() => createMatch()">
<MyInput
label="Opponent"
v-model="newMatchOpponent"
type="text"
required
/>
<MyInput
label="Date"
v-model="newMatchDate"
type="datetime-local"
required
/>
<button type="submit">Add</button>
</form>
<div v-if="mutationError">
{{ mutationError.response.data.message[0] }}
</div>
</div>
<div v-if="isLoading">Loading</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="match in matches" :key="match.id">
{{ `${match.opponent}: ${match.date}` }}
</li>
</ul>
</div>
</template>
Obviously this is a simplified example, but as you can see, there’s quite a bit of boilerplate code involved in setting up a simple feature like this.
- 73 lines of code in the backend
- 75 lines of code in the frontend
- Total: 148 lines of code 🤯
And like I mentioned before, there are still features missing like handling all error cases, authentication, styling etc.
This solution has several drawbacks:
- 🔄 Keeping the DTOs in sync between frontend and backend. In a larger project, you would probably want to share them in a separate package, which adds even more complexity.
- 💬 Serialization and deserialization of data between frontend and backend.
- ⚠️ There is no frontend validation. Everything is handled in the backend, which results in a bad user experience, more traffic, and more computation on the server
Don't get me wrong, I love NestJS and Vue, and they are great frameworks for building web applications - especially in enterprise environments. NestJS is a highly opinionated framework, which makes it great for working with multiple developers — or even multiple teams — on the same project.
However, for a side project where I want to move fast and minimize boilerplate code.
In Remix, I can build the same feature with much less code:
// app/routes/matches.tsx
const validator = withZod(
z.object({
opponent: z.string().min(1, { message: 'Opponent is required' }),
date: z.coerce.date({ message: 'Date must be a valid date' }),
})
);
export async function action({ request }: ActionFunctionArgs) {
const data = await parseFormData(validator, request);
await prisma.match.create({ data });
return null;
}
export async function loader() {
return {
matches: await prisma.match.findMany(),
};
}
export default function Matches() {
const { matches } = useLoaderData<typeof loader>();
return (
<div>
<div>
<ValidatedForm validator={validator} method="post">
<MyInput label="Opponent" name="opponent" />
<MyInput label="Date" name="date" type="datetime-local" />
<button type="submit">Add Match</button>
</ValidatedForm>
</div>
<ul>
{matches.map((match) => (
<li key={match.id}>{`${match.opponent}: ${match.date}`}</li>
))}
</ul>
</div>
);
}
With this implementation using Remix Validated Form, I have:
- 🚀 37 lines of code in total (about 75 % less than the NestJS + Vue version)
- 🔒 Full type safety between frontend and backend
- ✅ Built-in form validation with instant feedback to the user (client and server-side)
- 🚫 No need for an additional state management library like TanStack Query or Redux
- 📌 Reactivity on the frontend and persistent data on the backend in one place
- 🔍 Server-Side-Rendering for better SEO
- 📋 Less complexity: Just one file per feature
In conclusion, using Remix for my side project allowed me to build features faster and with less boilerplate code compared to my usual stack of NestJS and Vue.js. The built-in features of Remix, such as server-side rendering, routing, and form handling, made it easier to focus on building the actual functionality rather than dealing with the intricacies of frontend-backend communication.
In the next parts of this series, I’ll focus on code quality and testing, take a closer look at the dependencies I use, and explain how I handle hosting, bug tracking, and payments for the app.
I hope this article helps and inspires you to build more great side projects—whether just for fun, to learn something new, or to solve real-world problems you actually care about.
Stay tuned! ❤️
PS: If you're a soccer coach yourself, I’d love to get your feedback on ManageYourTeam! (The app is currently only available in German)

Top comments (0)