Mục tiêu: Kết nối .NET backend với React/Next.js frontend, pattern cho authentication, real-time, và deployment.
1. API Design: REST vs gRPC vs GraphQL
So Sánh
| Feature | REST | gRPC | GraphQL |
|---|---|---|---|
| Protocol | HTTP/1.1, JSON | HTTP/2, Protobuf | HTTP, JSON |
| Contract | OpenAPI/Swagger | .proto files | Schema + SDL |
| Performance | Tốt | Tốt nhất (~10x) | Tốt |
| Streaming | SSE/WebSocket | Bidirectional | Subscriptions |
| Browser | ✅ Native | ⚠️ grpc-web | ✅ Native |
| Use case | Public APIs | Service-to-service | Complex queries |
| .NET support | ASP.NET Core | Grpc.AspNetCore | HotChocolate |
REST API Best Practices cho Frontend
csharp// Consistent API response format
public class ApiResponse<T>
{
public T? Data { get; init; }
public bool Success { get; init; }
public string? Message { get; init; }
public Dictionary<string, string[]>? Errors { get; init; }
}
public class PagedResponse<T> : ApiResponse<IReadOnlyList<T>>
{
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalCount { get; init; }
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasNext => Page < TotalPages;
public bool HasPrevious => Page > 1;
}
// Controller
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class ProductsController(IMediator mediator) : ControllerBase
{
/// <summary>Get products with filtering and pagination</summary>
[HttpGet]
[ProducesResponseType<PagedResponse<ProductDto>>(200)]
public async Task<IActionResult> GetAll([FromQuery] GetProductsQuery query)
{
var result = await mediator.Send(query);
return Ok(result);
}
}
OpenAPI Codegen - Shared Types
bash# Generate TypeScript client từ .NET OpenAPI spec
npx openapi-typescript-codegen \
--input http://localhost:5001/swagger/v1/swagger.json \
--output ./src/api/generated \
--client axios
# Hoặc dùng orval (popular hơn, hỗ trợ TanStack Query)
npx orval --config ./orval.config.ts
typescript// orval.config.ts
export default {
api: {
input: 'http://localhost:5001/swagger/v1/swagger.json',
output: {
target: './src/api/generated.ts',
client: 'react-query', // Auto-generate TanStack Query hooks!
mode: 'tags-split',
},
},
};
// Generated output - zero manual typing:
// export const useGetProducts = (params?: GetProductsParams) => {
// return useQuery({ queryKey: ['products', params], queryFn: ... });
// };
2. Authentication Flow: .NET + Frontend
Pattern 1: JWT (.NET API + React SPA)
React App (Vite) .NET API
│ │
│ POST /api/auth/login │
│ { email, password } │
│─────────────────────────────────→│
│ │ Validate credentials
│ { accessToken, refreshToken } │ Generate tokens
│←─────────────────────────────────│
│ │
│ Store tokens in memory │
│ (KHÔNG localStorage!) │
│ │
│ GET /api/products │
│ Authorization: Bearer <token> │
│─────────────────────────────────→│
│ │ Validate JWT
│ { data: [...] } │
│←─────────────────────────────────│
tsx// React: Auth context + interceptor
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const accessTokenRef = useRef<string | null>(null); // Memory only!
const login = async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password });
accessTokenRef.current = response.data.accessToken;
setUser(response.data.user);
// Refresh token → httpOnly cookie (set by server)
};
// Axios interceptor: auto-attach token
useEffect(() => {
const interceptor = api.interceptors.request.use((config) => {
if (accessTokenRef.current) {
config.headers.Authorization = `Bearer ${accessTokenRef.current}`;
}
return config;
});
// Auto-refresh interceptor
const responseInterceptor = api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
try {
const { data } = await api.post('/auth/refresh');
accessTokenRef.current = data.accessToken;
error.config.headers.Authorization = `Bearer ${data.accessToken}`;
return api(error.config); // Retry original request
} catch {
setUser(null);
accessTokenRef.current = null;
}
}
return Promise.reject(error);
}
);
return () => {
api.interceptors.request.eject(interceptor);
api.interceptors.response.eject(responseInterceptor);
};
}, []);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
Pattern 2: Cookie Auth (.NET + Next.js)
csharp// .NET: Set httpOnly cookie cho refresh token
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request)
{
var user = await _authService.ValidateCredentials(request);
var tokens = _tokenService.GenerateTokens(user);
// httpOnly cookie - Next.js tự động gửi
Response.Cookies.Append("refresh-token", tokens.RefreshToken, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddDays(7),
Path = "/api/auth"
});
return Ok(new { accessToken = tokens.AccessToken, user = user.ToDto() });
}
tsx// Next.js: Auth.js (NextAuth v5) integration
// auth.ts
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
CredentialsProvider({
async authorize(credentials) {
// Call .NET API
const res = await fetch(`${process.env.API_URL}/api/auth/login`, {
method: 'POST',
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) return null;
const data = await res.json();
return { ...data.user, accessToken: data.accessToken };
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) token.accessToken = user.accessToken;
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
return session;
},
},
});
// Middleware protection
// middleware.ts
export { auth as middleware } from '@/auth';
export const config = {
matcher: ['/dashboard/:path*', '/orders/:path*'],
};
3. Real-Time: SignalR + React
csharp// .NET: SignalR Hub
public class OrderHub : Hub
{
public async Task JoinOrderRoom(string orderId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
}
public async Task LeaveOrderRoom(string orderId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"order-{orderId}");
}
}
// Broadcast từ service
public class OrderService(IHubContext<OrderHub> hubContext)
{
public async Task UpdateStatus(Guid orderId, OrderStatus status)
{
// Update DB...
// Notify connected clients
await hubContext.Clients
.Group($"order-{orderId}")
.SendAsync("OrderStatusChanged", new { orderId, status });
}
}
tsx// React: SignalR hook
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr';
function useSignalR(hubUrl: string) {
const [connection, setConnection] = useState<HubConnection | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const conn = new HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: () => getAccessToken(), // JWT
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(LogLevel.Information)
.build();
conn.start()
.then(() => {
setIsConnected(true);
setConnection(conn);
})
.catch(console.error);
conn.onreconnecting(() => setIsConnected(false));
conn.onreconnected(() => setIsConnected(true));
return () => { conn.stop(); };
}, [hubUrl]);
return { connection, isConnected };
}
// Usage in component
function OrderTracker({ orderId }: { orderId: string }) {
const { connection, isConnected } = useSignalR('/hubs/orders');
const [status, setStatus] = useState<string>('');
const queryClient = useQueryClient();
useEffect(() => {
if (!connection) return;
connection.invoke('JoinOrderRoom', orderId);
connection.on('OrderStatusChanged', (data) => {
setStatus(data.status);
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
});
return () => {
connection.invoke('LeaveOrderRoom', orderId);
connection.off('OrderStatusChanged');
};
}, [connection, orderId]);
return (
<div>
<StatusBadge connected={isConnected} />
<p>Current status: {status}</p>
</div>
);
}
4. Deployment: Docker + Azure
Docker Multi-Stage Build
dockerfile# ---- .NET API ---- FROM mcr.microsoft.com/dotnet/sdk:9.0 AS api-build WORKDIR /src COPY src/Api/*.csproj ./ RUN dotnet restore COPY src/Api/ ./ RUN dotnet publish -c Release -o /app/publish # ---- React Frontend ---- FROM node:20-alpine AS frontend-build WORKDIR /app COPY src/Web/package*.json ./ RUN npm ci COPY src/Web/ ./ RUN npm run build # ---- Final ---- FROM mcr.microsoft.com/dotnet/aspnet:9.0 WORKDIR /app COPY --from=api-build /app/publish ./ COPY --from=frontend-build /app/dist ./wwwroot EXPOSE 8080 ENTRYPOINT ["dotnet", "Api.dll"]
Azure Container Apps
yaml# docker-compose.azure.yml
services:
api:
image: myregistry.azurecr.io/myapp-api:latest
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__Default=<from-keyvault>
ports:
- "8080:8080"
web:
image: myregistry.azurecr.io/myapp-web:latest
environment:
- VITE_API_URL=https://api.myapp.com
ports:
- "3000:3000"
5. Monorepo Structure
my-project/
├── src/
│ ├── Api/ ← .NET Web API
│ │ ├── Api.csproj
│ │ └── Program.cs
│ ├── Domain/ ← .NET Domain layer
│ │ └── Domain.csproj
│ ├── Infrastructure/ ← .NET Infrastructure
│ │ └── Infrastructure.csproj
│ └── Web/ ← React + Vite (hoặc Next.js)
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
├── shared/
│ └── openapi.json ← OpenAPI spec (auto-generated)
├── scripts/
│ ├── generate-client.sh ← Generate TS client
│ └── dev.sh ← Start both servers
├── docker-compose.yml
├── MyProject.sln
└── README.md
json// Web/package.json scripts
{
"scripts": {
"dev": "vite",
"dev:full": "concurrently \"dotnet run --project ../Api\" \"vite\"",
"generate-api": "orval --config orval.config.ts",
"build": "tsc && vite build",
"preview": "vite preview"
}
}
Tổng Kết Session 11
| Concept | Key Takeaway |
|---|---|
| API Design | REST cho public API, OpenAPI codegen để share types |
| Auth (SPA) | JWT in memory + refresh token in httpOnly cookie |
| Auth (Next.js) | Auth.js/NextAuth + middleware protection |
| SignalR | Real-time updates, auto-reconnect, group-based messaging |
| Docker | Multi-stage builds cho .NET + Frontend |
| Monorepo | Shared OpenAPI spec, concurrent dev servers |
Bài tập: Setup monorepo với .NET API + React Vite, configure proxy, implement JWT auth flow, và dùng OpenAPI codegen (orval) để generate API client.