microsoft/migrating-spa-services-to-spa-proxy
> Migrates ASP.NET Core projects from the obsolete Microsoft.AspNetCore.SpaServices.Extensions to Microsoft.AspNetCore.SpaProxy for Angular and React SPAs. Use ONLY when Microsoft.AspNetCore.SpaServices.Extensions has been flagged as obsolete or deprecated and must be replaced — not for version-bump scenarios where SpaServices.Extensions is still supported.
npx skills add https://github.com/microsoft/upgrade-agent-plugins --skill migrating-spa-services-to-spa-proxy
Migrate ASP.NET Core projects from Microsoft.AspNetCore.SpaServices.Extensions to Microsoft.AspNetCore.SpaProxy. The backend no longer manages the SPA dev server inline via UseSpa(); instead, SpaProxy automatically launches the frontend dev server and proxies requests to it. This removes all SpaServices middleware from Startup.cs and moves configuration to the project file and hosting startup assembly.
Covers both Angular and React frontends. Steps are shared unless marked framework-specific.
Migration Progress:
- [ ] Step 1: Detect SpaServices usage and identify frontend framework
- [ ] Step 2: Update Startup.cs
- [ ] Step 3: Update the project file
- [ ] Step 4: Update launchSettings.json
- [ ] Step 5: Update package.json
- [ ] Step 6: Update angular.json (Angular only)
- [ ] Step 7: Add HTTPS and proxy configuration files
- [ ] Step 8: Build and verify
Scan the project for:
Microsoft.AspNetCore.SpaServices.ExtensionsAddSpaStaticFiles(), UseSpaStaticFiles(), UseSpa()spa.UseAngularCliServer() (Angular) or spa.UseReactDevelopmentServer() (React)Identify the frontend framework — Angular projects have angular.json, React projects have react-scripts in package.json. This determines which steps and files apply.
Remove three blocks of SpaServices middleware. No replacement middleware is needed — SpaProxy activates via a hosting startup assembly.
Remove AddSpaStaticFiles():
// DELETE this entire block
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/dist";
});
Remove UseSpaStaticFiles():
// DELETE this entire block
if (!env.IsDevelopment())
{
app.UseSpaStaticFiles();
}
Remove UseSpa() (Angular variant):
// DELETE this entire block
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
Remove UseSpa() (React variant):
// DELETE this entire block
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseReactDevelopmentServer(npmScript: "start");
}
});
Add fallback route inside app.UseEndpoints(...):
app.UseEndpoints(endpoints =>
{
// ... existing endpoints ...
endpoints.MapFallbackToFile("index.html");
});
Replace the package reference:
-<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="..." />
+<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="..." />
Add SpaProxy properties (choose a frontend port that does not conflict with the backend port):
<PropertyGroup>
<SpaProxyServerUrl>https://localhost:44416</SpaProxyServerUrl>
<SpaProxyLaunchCommand>npm start</SpaProxyLaunchCommand>
</PropertyGroup>
Update MSBuild targets to ensure Node.js availability and publish the SPA output to wwwroot:
For Angular (output goes to dist\ and dist-server\):
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
<Exec Command="node --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build -- --configuration production" />
<ItemGroup>
<DistFiles Include="$(SpaRoot)dist\**; $(SpaRoot)dist-server\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
For React (output goes to build\):
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
<Exec Command="node --version" ContinueOnError="true">
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
</Exec>
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
<ItemGroup>
<DistFiles Include="$(SpaRoot)build\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
Add the hosting startup assembly environment variable to each profile inside the profiles section:
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
Ensure the backend applicationUrl port differs from the frontend SpaProxyServerUrl port.
Add cross-platform start scripts with SSL using the ASP.NET Core dev certificate. Replace the frontend port to match SpaProxyServerUrl:
"start": "run-script-os",
"start:windows": "ng serve --port 44416 --ssl --ssl-cert %APPDATA%\\ASP.NET\\https\\%npm_package_name%.pem --ssl-key %APPDATA%\\ASP.NET\\https\\%npm_package_name%.key",
"start:default": "ng serve --port 44416 --ssl --ssl-cert $HOME/.aspnet/https/${npm_package_name}.pem --ssl-key $HOME/.aspnet/https/${npm_package_name}.key",
Add the run-script-os dependency:
"run-script-os": "^1.1.6"
Update start scripts to run HTTPS setup before launching the dev server:
"prestart": "node aspnetcore-https && node aspnetcore-react",
"start": "rimraf ./build && react-scripts start"
Add a proxy configuration to the development serve target:
"serve": {
"configurations": {
"development": {
"proxyConfig": "proxy.conf.js"
}
}
}
Add these files in the same directory as package.json (typically ClientApp/).
Sets up HTTPS using the ASP.NET Core development certificate:
// This script sets up HTTPS for the application using the ASP.NET Core HTTPS certificate
const fs = require('fs');
const spawn = require('child_process').spawn;
const path = require('path');
const baseFolder =
process.env.APPDATA !== undefined && process.env.APPDATA !== ''
? `${process.env.APPDATA}/ASP.NET/https`
: `${process.env.HOME}/.aspnet/https`;
const certificateArg = process.argv.map(arg => arg.match(/--name=(?<value>.+)/i)).filter(Boolean)[0];
const certificateName = certificateArg ? certificateArg.groups.value : process.env.npm_package_name;
if (!certificateName) {
console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass --name=<<app>> explicitly.')
process.exit(-1);
}
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);
if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
spawn('dotnet', [
'dev-certs',
'https',
'--export-path',
certFilePath,
'--format',
'Pem',
'--no-password',
], { stdio: 'inherit', })
.on('exit', (code) => process.exit(code));
}
Proxies API requests to the ASP.NET Core backend. Replace [IIS-HTTP-PORT] with the IIS Express port from Properties/launchSettings.json, and update the context array with the project's API route prefixes:
const { env } = require('process');
const target = env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` :
env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:[IIS-HTTP-PORT]';
const PROXY_CONFIG = [
{
context: [
"/weatherforecast",
],
target: target,
secure: false,
headers: {
Connection: 'Keep-Alive'
}
}
]
module.exports = PROXY_CONFIG;
Configures .env.development.local with SSL certificate paths for the React dev server:
const fs = require('fs');
const path = require('path');
const baseFolder =
process.env.APPDATA !== undefined && process.env.APPDATA !== ''
? `${process.env.APPDATA}/ASP.NET/https`
: `${process.env.HOME}/.aspnet/https`;
const certificateArg = process.argv.map(arg => arg.match(/--name=(?<value>.+)/i)).filter(Boolean)[0];
const certificateName = certificateArg ? certificateArg.groups.value : process.env.npm_package_name;
if (!certificateName) {
console.error('Invalid certificate name. Run this script in the context of an npm/yarn script or pass --name=<<app>> explicitly.')
process.exit(-1);
}
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);
if (!fs.existsSync('.env.development.local')) {
fs.writeFileSync(
'.env.development.local',
`SSL_CRT_FILE=${certFilePath}
SSL_KEY_FILE=${keyFilePath}`
);
} else {
let lines = fs.readFileSync('.env.development.local')
.toString()
.split('\n');
let hasCert, hasCertKey = false;
for (const line of lines) {
if (/SSL_CRT_FILE=.*/i.test(line)) {
hasCert = true;
}
if (/SSL_KEY_FILE=.*/i.test(line)) {
hasCertKey = true;
}
}
if (!hasCert) {
fs.appendFileSync(
'.env.development.local',
`\nSSL_CRT_FILE=${certFilePath}`
);
}
if (!hasCertKey) {
fs.appendFileSync(
'.env.development.local',
`\nSSL_KEY_FILE=${keyFilePath}`
);
}
}
Add in the same folder as package.json. Replace the port to match SpaProxyServerUrl:
PORT=44416
HTTPS=true
Add inside ClientApp/src/. Replace [IIS-HTTP-PORT] with the IIS Express port from Properties/launchSettings.json, and update the context array with the project's API route prefixes:
const { createProxyMiddleware } = require('http-proxy-middleware');
const { env } = require('process');
const target = env.ASPNETCORE_HTTPS_PORT ? `https://localhost:${env.ASPNETCORE_HTTPS_PORT}` :
env.ASPNETCORE_URLS ? env.ASPNETCORE_URLS.split(';')[0] : 'http://localhost:[IIS-HTTP-PORT]';
const context = [
"/weatherforecast",
];
module.exports = function(app) {
const appProxy = createProxyMiddleware(context, {
target: target,
secure: false,
headers: {
Connection: 'Keep-Alive'
}
});
app.use(appProxy);
};
dotnet builddotnet rundotnet publish — verify the SPA output lands in wwwroot/| File | Angular | React |
|------|---------|-------|
| Startup.cs changes | ✅ | ✅ |
| Project file changes | ✅ (dist output) | ✅ (build output) |
| launchSettings.json | ✅ | ✅ |
| package.json (start scripts) | ✅ (run-script-os) | ✅ (prestart + react-scripts) |
| angular.json | ✅ | — |
| aspnetcore-https.js | ✅ | ✅ |
| proxy.conf.js | ✅ | — |
| aspnetcore-react.js | — | ✅ |
| .env.development | — | ✅ |
| setupProxy.js (in src/) | — | ✅ |
The frontend dev server is not starting or is listening on the wrong port. Verify SpaProxyLaunchCommand is correct and SpaProxyServerUrl matches the actual dev server URL.
The dev server certificate is not trusted. Run dotnet dev-certs https --trust and ensure aspnetcore-https.js exported the certificate to the correct path.
The backend applicationUrl and frontend SpaProxyServerUrl must use different ports. Conflicting ports cause connection refused errors.
SpaProxy is development-only. For production, the PublishRunWebpack target in the project file must copy the SPA build output to wwwroot/. Verify the DistFiles include pattern matches the framework's output directory (dist\ for Angular, build\ for React).
Take microsoft/migrating-spa-services-to-spa-proxy from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npm.
Without those the skill loads but fails at the first command.