Vue with a .NET or NestJS backend: a step-by-step guide
Building a modern full-stack application requires more than just picking a frontend framework—you need to think through how your Vue frontend communicates with your backend, how you handle authentication, and how you deploy the whole thing. Whether you're pairing Vue with a .NET API or a NestJS backend, the architectural decisions you make early will ripple through your codebase, your CI/CD pipeline, and your team's day-to-day workflow.
We've learned this the hard way across multiple e-commerce and SaaS projects. This guide walks you through the key decisions and implementation patterns that work in production, starting with how to generate type-safe API clients and ending with deployment strategies that your ops team won't hate.
Typed API clients from OpenAPI
The most common pain point we see is developers writing API calls manually, then spending weeks hunting down bugs when the backend contract changes. A typed API client generated from OpenAPI solves this. Both .NET and NestJS ecosystems make this easy.
With NestJS, you can use the built-in Swagger/OpenAPI integration. A few decorators on your controllers automatically generate a spec:
import { Controller, Get } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
@ApiTags('users')
@Controller('users')
export class UsersController {
@Get(':id')
@ApiOkResponse({ type: UserDto })
getUser(@Param('id') id: string) {
return this.usersService.findOne(id);
}
}On the .NET side, Swashbuckle does the same job. Once your spec is live at /swagger/v1/swagger.json, you generate a Vue-compatible client using openapi-typescript:
import { paths } from './api.gen'; // Generated types
import createClient from 'openapi-fetch';
const client = createClient<paths>({ baseUrl: 'https://api.example.com' });
// Fully typed request and response
const { data, error } = await client.GET('/users/{id}', {
params: { path: { id: '123' } }
});The real win: if a backend engineer adds a required field or changes an endpoint path, your TypeScript compiler catches it before code review. We've prevented dozens of runtime errors this way in production CRM systems.
Store your generated client in a separate api/ directory, regenerate it in your CI pipeline before tests run, and never commit the generated files. This keeps everyone in sync.
CORS or same-domain proxy
Next decision: how does your browser actually reach the backend? Two paths, each with real trade-offs.
CORS (development, some production scenarios)
If your Vue app runs on localhost:3000 and your backend on localhost:3001, you need CORS headers. In NestJS, use the built-in middleware:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: process.env.CORS_ORIGIN?.split(',') || 'http://localhost:3000',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
});
await app.listen(3001);
}
bootstrap();In .NET, it's equally straightforward:
builder.Services.AddCors(options => {
options.AddPolicy("AllowVue", policy =>
policy
.WithOrigins(builder.Configuration["CorsOrigins"]?.Split(",") ?? new[] { "http://localhost:3000" })
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
app.UseCors("AllowVue");CORS is simple for local development but adds complexity at scale. You're trusting the browser to enforce security, and preflight requests add latency to every cross-origin call.
Same-domain proxy (recommended for production)
In production, serve Vue from the same domain as your API. Use a reverse proxy (Nginx, Caddy) or a framework-level router. If you're deploying together, your backend can even serve the built Vue app as static files.
In NestJS, serve static files from a dist folder at the root:
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'),
exclude: ['/api/*'],
}),
],
})
export class AppModule {}All your API routes stay under /api, everything else serves the Vue app. The browser makes same-origin requests, no CORS needed, and you gain a security boundary.
Auth with cookies or tokens
How you authenticate sets the tone for your entire backend. We'll cover two patterns that work in production.
Session cookies (simpler, but stateful)
This works best when Vue and your backend are on the same domain. Your NestJS or .NET backend creates a session after login, stores it in memory or Redis, and sends a secure HttpOnly cookie:
@Post('login')
async login(@Body() dto: LoginDto, @Res() res: Response) {
const user = await this.authService.validateUser(dto);
res.cookie('session_id', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ ok: true });
}The browser automatically includes this cookie on every request, so your Vue code doesn't need to manage tokens:
const { data } = await client.GET('/api/users/me');
// Cookie is sent automaticallyThe downside: your backend must store session state, which doesn't scale well across multiple servers. You'll need Redis or a shared session store.
JWT tokens (stateless, distributed)
If you're running multiple backend instances or need offline-friendly auth, use JWTs. Your backend issues a token after login, the client stores it (typically in memory or localStorage), and sends it with every request:
@Post('login')
async login(@Body() dto: LoginDto) {
const user = await this.authService.validateUser(dto);
const token = this.jwtService.sign({ sub: user.id, email: user.email });
return { access_token: token };
}In your Vue client, add an interceptor to attach the token:
import { useAuthStore } from '@/stores/auth';
export const apiClient = createClient<paths>({
baseUrl: '/api',
});
// Intercept requests
apiClient.use({
onRequest(req) {
const token = useAuthStore().token;
if (token) {
req.headers['Authorization'] = `Bearer ${token}`;
}
},
});JWTs are stateless—no server-side session storage needed—but you lose the ability to revoke a token instantly. Use short expiration times (15 minutes) with a refresh token strategy to mitigate this.
For cookie-based auth with same-domain deployment, we recommend session cookies. For multi-region or microservice architectures, JWTs win.
Deploy separately or together
This decision shapes your CI/CD pipeline, your team structure, and your operational overhead.
Separate deployments
Vue runs on a CDN or static host (Vercel, Netlify, S3+CloudFront). Your backend lives on a container platform (ECS, Kubernetes, Railway). They communicate over a public API:
- Pros: Independent scaling, faster Vue deploys (no backend rebuild), clear separation of concerns.
- Cons: CORS complexity, harder to coordinate schema changes, more infrastructure moving parts.
We use this for teams where frontend and backend move at different velocities, like in high-traffic e-commerce where the checkout flow (backend-heavy) stabilizes while the marketing site (frontend-heavy) iterates weekly.
Monorepo deployment
Frontend and backend in one repo, deployed as a single unit. Your Dockerfile builds both, your CI pipeline runs all tests together, and they ship to production in lockstep:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build:backend
RUN pnpm run build:frontend
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist/backend ./dist/backend
COPY --from=builder /app/dist/frontend ./public
EXPOSE 3000
CMD ["node", "dist/backend/main.js"]Advantages: guaranteed API/frontend compatibility, one deployment pipeline, easier auth (same-domain cookies), simpler local development. Disadvantages: slower iteration on frontend-only changes, tight coupling.
We favor this for mid-size SaaS where the team is co-located and the app's frontend and backend are tightly integrated, like a CRM or trading platform.
Common mistakes
- Skipping OpenAPI generation: "We'll keep the client in sync manually" always fails. Use code generation.
- Enabling CORS for production: It's meant for development. Use a proxy in production.
- Storing JWTs in localStorage: They're vulnerable to XSS. Keep them in memory; use httpOnly cookies for refresh tokens if you need persistence.
- Forgetting to handle token refresh: If your access token expires mid-request, your app will silently fail unless you implement a refresh flow.
- Not versioning your API: Even with OpenAPI, breaking changes happen. Use
/api/v1and/api/v2endpoints to roll out changes safely. - Assuming same-domain auth "just works": Set
SameSiteandSecureflags correctly on cookies, or they won't be sent in certain scenarios.
Key takeaways
- Generate your Vue API client from OpenAPI specs in your CI pipeline—never write API contracts by hand.
- Use same-domain deployment with a reverse proxy or static file serving in production to avoid CORS headaches.
- Choose session cookies for monorepo deployments, JWTs for distributed systems.
- If Vue and backend iterate at different speeds, deploy separately. If they're tightly coupled, use a monorepo.
- Set up token refresh and expiration handling before your app goes to production.






Comments
Loading comments…